
Active Record is the right default for a Rails app: every gem, generator, and guide assumes it. Sequel wins when you want SQL-shaped, immutable datasets, opt-in plugins instead of a framework, or an ORM outside Rails. Both run on Ruby 3.4 (Active Record 8.1.3.1, Sequel 5.108.0, verified) and can share one connection inside a Rails app.
Every snippet on this page ran in a container on Ruby 3.4.10 against PostgreSQL 17.11, with Active Record 8.1.3.1 and Sequel 5.108.0. The walkthrough reproduces the same three operations on both libraries (filtering, row locking, and transactions) and prints the SQL each one generated. It then covers eager loading, validations, migrations, sharding, running Sequel inside a Rails app, and the errors you hit when you cross from one library to the other.
Active Record vs Sequel at a Glance
| Active Record 8.1 | Sequel 5.108 | |
|---|---|---|
| Where it lives | Bundled with Rails; config/database.yml | Standalone gem; DB = Sequel.connect(url) |
| Query object | ActiveRecord::Relation, lazy, to_sql | Sequel::Dataset, lazy and frozen, .sql; .all returns hashes, models via Sequel::Model |
| Models | ActiveRecord::Base, has_many/belongs_to, concerns | Sequel::Model, one_to_many/many_to_one, opt-in plugins |
| Eager loading | includes (picks preload or eager_load), preload, eager_load | eager (extra query per association) or eager_graph (one LEFT OUTER JOIN; filter on it) |
| Row locks | lock → FOR UPDATE, with_lock; lock("FOR SHARE") | for_update, for_share, lock_style, Model#lock!; Dataset#lock(:exclusive) → LOCK TABLE |
| Optimistic locking | Automatic with a lock_version column → StaleObjectError | plugin :optimistic_locking + lock_version → NoExistingObject |
| Nested transactions | Inner ActiveRecord::Rollback is swallowed; outer commits unless requires_new | Inner Sequel::Rollback rolls the whole transaction back unless savepoint: true |
| Validations | validates … → RecordInvalid: Validation failed: Name can't be blank | plugin :validation_helpers → ValidationFailed: name is not present |
| Migrations | ActiveRecord::Migration[8.1], schema_migrations | Sequel.migration do change do … end end, Sequel::Migrator (schema_info or schema_migrations) |
| Replicas and shards | connects_to database:/shards:, connected_to(role:, shard:) | servers: { read_only: …, shard_one: … }; SELECTs route to :read_only; .server(:shard_one) |
| Inside a Rails app | The default | sequel-activerecord_connection 2.0.1 (shares the Active Record connection) or sequel-rails 1.2.4 (replaces Active Record) |
| Ruby 3.4 / Rails 8.1 | Yes (8.1.3.1, verified) | Yes (5.108.0, verified; both bridge gems verified on Rails 8.1.3.1) |
Per use case, the choice comes down to this:
- A Rails app → Active Record. The ecosystem assumes it; reach for Sequel through the bridge gem only for the queries Active Record fights you on.
- A non-Rails Ruby service, or SQL-first work → Sequel. Datasets are immutable,
.sqlworks on everything, and plugins load only when you ask for them. - Multiple databases from day one → both can. Sequel’s per-dataset
.serverand automatic read routing are the simpler model; Active Record’sconnects_tois the Rails-integrated one.
What Is Object-Relational Mapping (ORM)?
An object-relational mapper (ORM) translates between Ruby objects and the rows of a relational database such as PostgreSQL, MySQL, or SQLite. You read and write records through classes and methods instead of hand-written SQL. Active Record and Sequel are Ruby’s two mainstream implementations of that idea, and they make different choices about how much of the SQL to hide.
Introducing Active Record and Sequel for Ruby
Both libraries run against the same six PostgreSQL tables: products (name, price), orders (product_id, quantity), invoices (status, total_amount, and an integer lock_version column that defaults to 0), people (name), accounts (person_id, account_total), and customers (name).
Active Record
Active Record ships with Rails. In a Rails app, config/database.yml configures the connection and Rails opens it for you; in a plain Ruby script, you connect by hand:
require "active_record"
ActiveRecord::Base.establish_connection(ENV.fetch("DATABASE_URL"))The connection pool defaults to five connections when the configuration sets no pool:
ActiveRecord::Base.connection_pool.size # => 5Models subclass ActiveRecord::Base (ApplicationRecord in Rails), infer their table from the class name, and declare associations with has_many and belongs_to:
# models/order.rb
class Order < ActiveRecord::Base
belongs_to :product
end
# models/product.rb
class Product < ActiveRecord::Base
has_many :orders
endShared behavior goes into concerns, and validations, callbacks, optimistic locking, and the query interface are all loaded whether you use them or not.
Sequel
Sequel is a standalone gem. Connect first, because a Sequel::Model subclass reads its table’s schema the moment the class is defined:
require "sequel"
DB = Sequel.connect(ENV.fetch("DATABASE_URL")) # "postgres://pg:pw@localhost/demo_sequel"Sequel’s pool is a Sequel::TimedQueueConnectionPool with four connections unless you pass max_connections::
DB.pool.class # => Sequel::TimedQueueConnectionPool
DB.pool.max_size # => 4Models subclass Sequel::Model; the association macros are one_to_many and many_to_one, and anything beyond the core (validation helpers, timestamps, optimistic locking) is a plugin you enable per model:
# models/product.rb
class Product < Sequel::Model
one_to_many :orders
end
# models/order.rb
class Order < Sequel::Model
many_to_one :product
endThe feature that shapes everything else in Sequel is the dataset: a frozen representation of one SQL query. Every filtering method returns a new dataset, .sql shows the exact statement, and nothing runs until you call a method that needs rows:
orders = DB[:orders]
orders.frozen? # => true
puts orders.sql
puts orders.where(quantity: 2).sql
puts orders.sql # unchanged: where returned a new datasetSELECT * FROM "orders"
SELECT * FROM "orders" WHERE ("quantity" = 2)
SELECT * FROM "orders"Filtering Records
The task for both libraries: return every order for a product priced under 20.
Using Active Record
Active Record composes conditions on an ActiveRecord::Relation with methods such as where, joins, limit, and where.not. With the Order and Product models defined, the query is a joins plus a where:
product_orders_under_20 = Order.joins(:product).where('products.price < ?', 20)
puts product_orders_under_20.to_sqlSELECT "orders".* FROM "orders" INNER JOIN "products" ON "products"."id" = "orders"."product_id" WHERE (products.price < 20)The relation is lazy: building it runs nothing, and to_a (or iterating) executes the query and returns Order instances:
product_orders_under_20.is_a?(ActiveRecord::Relation) # => true
product_orders_under_20.loaded? # => nil
orders = product_orders_under_20.to_a
orders.size # => 2
orders.first.class # => OrderThe string condition works, but the hash form quotes the column for you and accepts a beginless range for the comparison:
puts Order.joins(:product).where(products: { price: ...20 }).to_sqlSELECT "orders".* FROM "orders" INNER JOIN "products" ON "products"."id" = "orders"."product_id" WHERE "products"."price" < 20.0For joins across several associations and conditions on the joined tables, the Active Record query guide’s section on complex joins goes further.
Using Sequel
The same query as a dataset, built straight on the table with a virtual-row block for the comparison:
product_orders_under_20 = DB[:orders].join(:products, id: :product_id).where { price < 20 }
puts product_orders_under_20.sqlSELECT * FROM "orders" INNER JOIN "products" ON ("products"."id" = "orders"."product_id") WHERE ("price" < 20)Building the dataset ran no SQL (the container’s query log stayed empty until .all), and the result of .all is an array of hashes, not model instances:
product_orders_under_20.class # => Sequel::Postgres::Dataset
product_orders_under_20.frozen? # => true
rows = product_orders_under_20.all
rows.size # => 2
rows.first.class # => Hash
rows.first.keys # => [:id, :product_id, :quantity, :name, :price]Those keys show a difference from the Active Record version: SELECT * over a join returns both tables’ columns, and the two id columns collapse into one key. Add select_all(:orders) to get only the orders’ columns, or start from the model and use association_join, which returns Order instances:
puts product_orders_under_20.select_all(:orders).sql
puts Order.association_join(:product).where { price < 20 }.select_all(:orders).sql
Order.association_join(:product).where { price < 20 }.select_all(:orders).all.first.class # => OrderSELECT "orders".* FROM "orders" INNER JOIN "products" ON ("products"."id" = "orders"."product_id") WHERE ("price" < 20)
SELECT "orders".* FROM "orders" INNER JOIN "products" AS "product" ON ("product"."id" = "orders"."product_id") WHERE ("price" < 20)Database Locking
Two editors open the same invoice and both save; one write has to win, and the loser has to find out. Both libraries offer the same two tools for that. Optimistic locking detects the conflict at save time through a version column; pessimistic locking takes a database row lock up front.
Using Active Record
Optimistic locking in Active Record is automatic once the table has an integer lock_version column (t.integer :lock_version, default: 0, null: false in a migration). Every save then checks the version it loaded:
invoice1 = Invoice.find(1)
invoice2 = Invoice.find(1)
invoice1.total_amount = 100
invoice1.save # => true
invoice2.total_amount = 500
invoice2.save # raises ActiveRecord::StaleObjectErrorThe first save runs an UPDATE that bumps the version and matches on the old one; the second runs the same statement, matches zero rows, and raises:
UPDATE "invoices" SET "total_amount" = $1, "lock_version" = $2 WHERE "invoices"."id" = $3 AND "invoices"."lock_version" = $4For pessimistic locking, lock adds FOR UPDATE to the SELECT. A row lock lasts only as long as the surrounding transaction, so wrap the lookup and the write in one:
Invoice.transaction do
invoice = Invoice.lock.find(1)
invoice.update!(status: "Sent")
endBEGIN
SELECT "invoices".* FROM "invoices" WHERE "invoices"."id" = $1 LIMIT $2 FOR UPDATE
UPDATE "invoices" SET "status" = $1, "lock_version" = $2 WHERE "invoices"."id" = $3 AND "invoices"."lock_version" = $4
COMMITwith_lock does the same for a record you already hold: it opens a transaction, reloads the row with FOR UPDATE, and yields:
invoice = Invoice.find(1)
invoice.with_lock do
invoice.update!(status: "Paid")
endA bare Invoice.lock.find(1) outside a transaction still sends FOR UPDATE, but PostgreSQL releases the lock as soon as the statement returns, so it protects nothing. lock also accepts a raw clause for other lock modes. On SQLite, both libraries drop the lock clause silently (the container’s to_sql on SQLite came back with no FOR UPDATE at all):
puts Invoice.lock("FOR SHARE").where(id: 1).to_sqlSELECT "invoices".* FROM "invoices" WHERE "invoices"."id" = 1 FOR SHAREThe Rails guide on locking records for update covers the remaining options.
Using Sequel
Sequel’s optimistic locking is a plugin, and it needs the same lock_version column:
# models/invoice.rb
class Invoice < Sequel::Model
plugin :optimistic_locking
endThe primary-key lookup is Invoice[1], not Invoice.find(1): find with an integer means “the first N rows” in Sequel and returns an array. The error section at the end covers the fallout. The two-instance conflict then looks like this:
invoice1 = Invoice[1]
invoice2 = Invoice[1]
invoice1.update(status: 'Sent') # record will be updated
invoice2.update(status: 'Void') # raises Sequel::NoExistingObjectSequel logs the version check with the literal values inlined, and wraps each model save in its own transaction:
BEGIN
UPDATE "invoices" SET "status" = 'Sent', "lock_version" = 1 WHERE (("id" = 1) AND ("lock_version" = 0))
COMMITFor pessimistic locking, for_update adds FOR UPDATE to a dataset. The old version of this post described the next snippet as applying a lock; it does not. invoice1 is a dataset, building it runs nothing, and the lock only exists while a transaction that executed the statement is open:
dataset = DB[:invoices]
invoice1 = dataset.where(id: 1).for_update # a dataset, not a row; runs no SQL yet
puts invoice1.sqlSELECT * FROM "invoices" WHERE ("id" = 1) FOR UPDATEThe working form executes the locked SELECT inside DB.transaction and writes before it commits:
DB.transaction do
invoice = Invoice.where(id: 1).for_update.first
invoice.update(status: 'Sent')
endBEGIN
SELECT * FROM "invoices" WHERE ("id" = 1) LIMIT 1 FOR UPDATE
UPDATE "invoices" SET "status" = 'Sent', "lock_version" = 1 WHERE (("id" = 1) AND ("lock_version" = 0))
COMMITfor_share and lock_style cover the other modes, Invoice[1].lock! reloads a model instance with FOR UPDATE inside the current transaction, and DB[:invoices].lock(:exclusive) takes a table lock (LOCK TABLE "invoices" IN EXCLUSIVE MODE in the log):
puts DB[:invoices].where(id: 1).for_share.sql
puts DB[:invoices].where(id: 1).lock_style('FOR UPDATE NOWAIT').sqlSELECT * FROM "invoices" WHERE ("id" = 1) FOR SHARE
SELECT * FROM "invoices" WHERE ("id" = 1) FOR UPDATE NOWAITJeremy Evans’ post on pessimistic locking in Sequel explains the design.
Attempted to update a stale object: Invoice. (ActiveRecord::StaleObjectError)
/usr/local/bundle/gems/activerecord-8.1.3.1/lib/active_record/locking/optimistic.rb:119:in 'ActiveRecord::Locking::Optimistic#_update_row': Attempted to update a stale object: Invoice. (ActiveRecord::StaleObjectError)Cause: The record you are saving carries a lock_version that no longer matches the row, because another instance (or another process) saved first. The UPDATE matched zero rows.
Fix: Reload the record, reapply your change, and save again; or, when the second writer must not lose, take a FOR UPDATE lock before reading:
begin
invoice2.save!
rescue ActiveRecord::StaleObjectError
invoice2.reload
invoice2.total_amount = 500
invoice2.save!
endIn the container, the retry saved total_amount 500 and left lock_version at 2. Whether reapplying blindly is correct depends on the field: a status transition usually needs a decision, a counter usually does not.
Attempt to update object did not result in a single row modification (Sequel::NoExistingObject)
/usr/local/bundle/gems/sequel-5.108.0/lib/sequel/model/base.rb:2038:in 'Sequel::Model::InstanceMethods#_update': Attempt to update object did not result in a single row modification (SQL: UPDATE "invoices" SET "status" = 'Void', "lock_version" = 1 WHERE (("id" = 1) AND ("lock_version" = 0))) (Sequel::NoExistingObject)Cause: Sequel’s counterpart to StaleObjectError, raised by the optimistic_locking plugin when the version in the WHERE clause no longer matches; the message includes the statement that matched zero rows. The same class fires without the plugin when the row was deleted under you.
Fix: refresh (Sequel’s name for reload) pulls the current row into the instance; update again after it, or lock the row with for_update before you read it:
begin
invoice2.update(status: 'Void')
rescue Sequel::NoExistingObject
invoice2.refresh
invoice2.update(status: 'Void')
endDatabase Transactions
A transfer between two accounts has to withdraw from one and deposit into the other, or do neither. Both libraries expose that as a block; the interesting differences are in what happens when you roll back, and when the block is nested.
Using Active Record
withdraw and deposit are not ORM methods, so define them on the model (the old version of this post called them without ever showing them):
# models/account.rb
class Account < ActiveRecord::Base
belongs_to :person
def withdraw(amount)
update!(account_total: account_total - amount)
end
def deposit(amount)
update!(account_total: account_total + amount)
end
endAccount.transaction (the class method that ActiveRecord::Transactions adds to every model) wraps the two writes; if either raises, both are rolled back and the exception propagates:
a_account = Person.find_by(name: 'A').accounts.first
b_account = Person.find_by(name: 'B').accounts.first
Account.transaction do
a_account.withdraw(250)
b_account.deposit(250)
endAfter the two lookups, the block logs exactly one transaction:
BEGIN
UPDATE "accounts" SET "account_total" = $1 WHERE "accounts"."id" = $2
UPDATE "accounts" SET "account_total" = $1 WHERE "accounts"."id" = $2
COMMITTo abandon a transaction without an error reaching the caller, raise ActiveRecord::Rollback. The block swallows it and returns nil:
result = Account.transaction do
a_account.withdraw(50)
raise ActiveRecord::Rollback
end
result # => nilA nested block needs requires_new: true to get its own savepoint; then an inner rollback undoes only the inner writes:
Account.transaction do
a_account.withdraw(10)
Account.transaction(requires_new: true) do
b_account.withdraw(1000)
raise ActiveRecord::Rollback
end
endBEGIN
UPDATE "accounts" SET "account_total" = $1 WHERE "accounts"."id" = $2
SAVEPOINT active_record_1
UPDATE "accounts" SET "account_total" = $1 WHERE "accounts"."id" = $2
ROLLBACK TO SAVEPOINT active_record_1
COMMITUsing Sequel
The Sequel Account model has the same two methods, built on update (which raises on failure by default in Sequel, so there is no bang variant to reach for):
# models/account.rb
class Account < Sequel::Model
many_to_one :person
def withdraw(amount)
update(account_total: account_total - amount)
end
def deposit(amount)
update(account_total: account_total + amount)
end
endTransactions belong to the database object, not the model, so the block is DB.transaction:
DB.transaction do
Person.first(name: "A").accounts.first.withdraw(250)
Person.first(name: "B").accounts.first.deposit(250)
endSequel’s log shows the lookups inside the transaction, with every value inlined:
BEGIN
SELECT * FROM "people" WHERE ("name" = 'A') LIMIT 1
SELECT * FROM "accounts" WHERE ("accounts"."person_id" = 1)
UPDATE "accounts" SET "account_total" = 100 WHERE ("id" = 1)
SELECT * FROM "people" WHERE ("name" = 'B') LIMIT 1
SELECT * FROM "accounts" WHERE ("accounts"."person_id" = 2)
UPDATE "accounts" SET "account_total" = 350 WHERE ("id" = 2)
COMMITSequel::Rollback is the silent rollback, and the block returns nil the same way:
result = DB.transaction do
Person.first(name: "A").accounts.first.withdraw(50)
raise Sequel::Rollback
end
result # => nilSavepoints are opt-in with savepoint: true, and Sequel names them autopoint_1, autopoint_2, and so on:
DB.transaction do
Person.first(name: "A").accounts.first.withdraw(10)
DB.transaction(savepoint: true) do
Person.first(name: "B").accounts.first.withdraw(1000)
raise Sequel::Rollback
end
endBEGIN
SELECT * FROM "people" WHERE ("name" = 'A') LIMIT 1
SELECT * FROM "accounts" WHERE ("accounts"."person_id" = 1)
UPDATE "accounts" SET "account_total" = 340 WHERE ("id" = 1)
SAVEPOINT autopoint_1
SELECT * FROM "people" WHERE ("name" = 'B') LIMIT 1
SELECT * FROM "accounts" WHERE ("accounts"."person_id" = 2)
UPDATE "accounts" SET "account_total" = -900 WHERE ("id" = 2)
ROLLBACK TO SAVEPOINT autopoint_1
COMMITThe old version of this post said Sequel enables transactions by default “in some situations”. The setting is Sequel::Model.use_transactions, it defaults to true, and it means every model save or update outside an explicit block gets its own BEGIN and COMMIT:
Sequel::Model.use_transactions # => true
Invoice[1].update(status: 'Paid')BEGIN
UPDATE "invoices" SET "status" = 'Paid', "lock_version" = 1 WHERE (("id" = 1) AND ("lock_version" = 0))
COMMITSequel’s transactions guide documents isolation levels, retries, and after-commit hooks.
Nested Transactions: Where the Two Libraries Disagree
Run the same shape on both: an outer block that writes, an inner block that raises the library’s silent rollback, no savepoint option. Account A starts at 350.
Active Record without requires_new joins the inner block to the outer transaction, swallows the Rollback at the inner boundary, and commits the outer work:
Account.transaction do
a_account.withdraw(10)
Account.transaction do
raise ActiveRecord::Rollback
end
end
a_account.reload.account_total # => 340BEGIN
UPDATE "accounts" SET "account_total" = $1 WHERE "accounts"."id" = $2
COMMITSequel without savepoint: true treats the inner block as part of the outer one too, but its Rollback rolls the whole transaction back:
DB.transaction do
Person.first(name: "A").accounts.first.withdraw(10)
DB.transaction do
raise Sequel::Rollback
end
end
Person.first(name: "A").accounts.first.account_total # => 350BEGIN
SELECT * FROM "people" WHERE ("name" = 'A') LIMIT 1
SELECT * FROM "accounts" WHERE ("accounts"."person_id" = 1)
UPDATE "accounts" SET "account_total" = 340 WHERE ("id" = 1)
ROLLBACKIf you port a service object from one library to the other, this is the behavior to re-test. Our post on designing Rails transactions, in its section on nested transactions and requires_new, has the Active Record side in more depth.
Eager Loading, Validations, Migrations, and Sharding
The three areas so far are where the libraries look most alike. The next four are where you feel the design difference day to day.
Eager Loading: includes vs eager and eager_graph
Active Record’s includes decides for you. With no condition on the association it preloads with a second query; as soon as a where references the joined table, it switches to one LEFT OUTER JOIN with t0_r0-style aliases:
Product.includes(:orders).to_aSELECT "products".* FROM "products"
SELECT "orders".* FROM "orders" WHERE "orders"."product_id" IN ($1, $2)Product.includes(:orders).where(orders: { quantity: 2 }).to_aSELECT "products"."id" AS t0_r0, "products"."name" AS t0_r1, "products"."price" AS t0_r2, "orders"."id" AS t1_r0, "orders"."product_id" AS t1_r1, "orders"."quantity" AS t1_r2 FROM "products" LEFT OUTER JOIN "orders" ON "orders"."product_id" = "products"."id" WHERE "orders"."quantity" = $1preload and eager_load pick one strategy explicitly, and strict_loading turns any lazy association load into an error:
Product.strict_loading.first.orders.to_a # raises ActiveRecord::StrictLoadingViolationError`Product` is marked for strict_loading. The Order association named `:orders` cannot be lazily loaded.Sequel splits the two strategies into two methods and never switches between them. eager runs one extra query per association, like preload:
Product.eager(:orders).allSELECT * FROM "products"
SELECT * FROM "orders" WHERE ("orders"."product_id" IN (1, 2))eager_graph runs a single LEFT OUTER JOIN with aliased columns, like eager_load, and it is the one you need when a condition references the joined table:
puts Product.eager_graph(:orders).where(Sequel[:orders][:quantity] => 2).sqlSELECT "products"."id", "products"."name", "products"."price", "orders"."id" AS "orders_id", "orders"."product_id", "orders"."quantity" FROM "products" LEFT OUTER JOIN "orders" ON ("orders"."product_id" = "products"."id") WHERE ("orders"."quantity" = 2)Put the same condition on eager and PostgreSQL rejects the query, because the orders table is not in the first statement at all:
Product.eager(:orders).where(Sequel[:orders][:quantity] => 2).all # raises Sequel::DatabaseErrorSequel::DatabaseError: PG::UndefinedTable: ERROR: missing FROM-clause entry for table "orders"
LINE 1: SELECT * FROM "products" WHERE ("orders"."quantity" = 2)Either way, the N+1 problem is the same and so is the detection tooling; the database performance guide’s N+1 section covers Bullet, Prosopite, and strict_loading.
Validations and Associations
Active Record validations are declarative and always loaded. A failed create! raises RecordInvalid with the humanized message:
class Customer < ActiveRecord::Base
validates :name, presence: true
end
Customer.create!(name: nil) # raises ActiveRecord::RecordInvalid
customer = Customer.new(name: nil)
customer.valid? # => false
customer.errors.full_messages # => ["Name can't be blank"]
customer.save # => falseActiveRecord::RecordInvalid: Validation failed: Name can't be blankSequel validates in a validate method, with the validation_helpers plugin supplying the checks. Its messages are lowercase column names. A failed save raises by default (raise_on_save_failure is true), so save(raise_on_failure: false) is how you get the nil that Active Record’s save gives you as false:
class Customer < Sequel::Model
plugin :validation_helpers
def validate
super
validates_presence :name
end
end
Customer.create(name: nil) # raises Sequel::ValidationFailed
customer = Customer.new(name: nil)
customer.valid? # => false
customer.errors.full_messages # => ["name is not present"]
customer.save(raise_on_failure: false) # => nilSequel::ValidationFailed: name is not presentAssociations map one to one: has_many is one_to_many, belongs_to is many_to_one, has_and_belongs_to_many is many_to_many, and both libraries eager load, add, and remove through them. Sequel’s validations guide and association basics guide list the helpers and options.
Migrations
Active Record migrations are versioned classes; on 8.1, ActiveRecord::Migration.current_version is 8.1, and t.timestamps creates NOT NULL created_at and updated_at columns (which matters later, when Sequel writes to a Rails table):
class CreateWidgets < ActiveRecord::Migration[8.1]
def change
create_table :widgets do |t|
t.string :name, null: false
t.timestamps
end
end
endSequel migrations are Sequel.migration blocks with the same reversible change idea, but column types are Ruby classes and the primary key is explicit:
# db/migrations/001_create_widgets.rb
Sequel.migration do
change do
create_table(:widgets) do
primary_key :id
String :name, null: false
DateTime :created_at
end
end
endThey run through the migration extension. Integer-prefixed files use the integer migrator, which tracks a single version in schema_info; timestamp-prefixed files use the timestamp migrator and a schema_migrations table keyed by filename:
Sequel.extension :migration
Sequel::Migrator.run(DB, "db/migrations")
DB[:schema_info].all # => [{version: 1}]Sequel’s migration guide covers up/down blocks, the sequel -m command, and rollback targets.
Read Replicas and Sharding
Active Record routes with connects_to on an abstract class and connected_to blocks; reads on the reading role go to the replica, writes there raise, and shards switch by name:
class ApplicationRecord < ActiveRecord::Base
primary_abstract_class
connects_to shards: {
default: { writing: :primary, reading: :replica },
shard_one: { writing: :primary_shard_one, reading: :replica_shard_one }
}
end
class Product < ApplicationRecord; endActiveRecord::Base.connected_to(role: :reading) { Product.count }
ActiveRecord::Base.connected_to(role: :reading) { Product.create!(name: "Pen") } # raises ActiveRecord::ReadOnlyError
ActiveRecord::Base.connected_to(shard: :shard_one) { Product.create!(name: "Pen") }ActiveRecord::ReadOnlyError: Write query attempted while in readonly mode: INSERT INTO "products" ("name") VALUES ($1) RETURNING "id"Rails also ships automatic role-switching middleware; the multiple databases guide covers it, and the database performance guide puts read replicas and sharding in context.
Sequel takes a servers: hash at connect time. A server named :read_only receives every plain SELECT with no switching code at all, writes go to :default, and any dataset can be pointed at a named server with .server:
DB = Sequel.connect(
ENV.fetch("DATABASE_URL"),
servers: {
read_only: { database: "demo_sequel2_ro" },
shard_one: { database: "demo_sequel2_shard" }
}
)
DB.pool.class # => Sequel::ShardedTimedQueueConnectionPool
DB[:products].insert(name: "Pen") # INSERT runs on :default
DB[:products].all # SELECT runs on :read_only
DB[:products].server(:default).all # SELECT forced onto :default
DB[:products].server(:shard_one).all
DB.extension :server_block
DB.with_server(:shard_one) { DB[:products].all }The container proved the routing with three separate databases holding different rows: DB[:products].all returned the :read_only row, .server(:default) the freshly inserted Pen, and .server(:shard_one) the shard’s row. For models, the sharding plugin keeps an instance on the server it was loaded from, so a later update runs on the same shard. Bind the model to the sharded DB explicitly if it is not the first database you connected to, because Sequel::Model defaults to the first connection:
class Product < Sequel::Model(DB[:products])
plugin :sharding
end
product = Product.server(:shard_one).first
product.update(name: "Renamed on shard_one") # the UPDATE stays on :shard_oneOne caveat from the container: DB.create_table(:name, server: :shard_one) did not route the DDL to the shard; run schema changes inside with_server or through a direct connection. Sequel’s sharding guide covers the rest, including per-model server routing.
Connections and Threads
Both pools are thread-safe. The defaults differ: Active Record’s pool is 5 and Sequel’s max_connections is 4, and both are meant to be raised to match your server’s thread count. Sequel also has a single_threaded: true option that swaps the pool for a Sequel::SingleConnectionPool with no locking at all, which suits scripts and single-threaded workers. max_connections: 10 raises the ceiling when Sequel runs under a threaded server such as Puma.
Using Sequel Inside a Rails App
The two libraries are not either/or. A Rails app can keep Active Record and hand a few queries to Sequel, or replace Active Record altogether. Both paths were verified in fresh rails new apps on Rails 8.1.3.1.
Sharing Active Record’s Connection with sequel-activerecord_connection
The sequel-activerecord_connection gem (2.0.1 in the container) makes Sequel borrow Active Record’s connection and transaction state instead of opening its own:
bundle add sequel-activerecord_connection# config/initializers/sequel.rb
require "sequel"
DB = Sequel.postgres(extensions: :activerecord_connection)Sequel then sees Active Record’s tables, and a Sequel write inside an Active Record transaction rolls back with it. This script ran under bin/rails runner against a products table created by bin/rails generate model Product name:string price:decimal:
# check.rb, run with: bin/rails runner check.rb
Product.create!(name: "Pen", price: 5)
puts DB[:products].select(:name).all.inspect
ActiveRecord::Base.transaction do
DB[:products].insert(name: "Sequel pen", price: 1, created_at: Time.now, updated_at: Time.now)
puts "inside the block: in_transaction?=#{DB.in_transaction?} rows=#{Product.count}"
raise ActiveRecord::Rollback
end
puts "after the rollback: rows=#{Product.count}"
puts "#{DB.pool.class}, Active Record pool size #{ActiveRecord::Base.connection_pool.size}"$ bin/rails runner check.rb
[{name: "Pen"}]
inside the block: in_transaction?=true rows=2
after the rollback: rows=1
Sequel::TimedQueueConnectionPool, Active Record pool size 5Sequel reports its usual pool class, but the connections inside it are Active Record’s; the Rails pool (size 5) stays the one that matters for sizing.
null value in column "created_at" of relation "products" violates not-null constraint (Sequel::NotNullConstraintViolation)
PG::NotNullViolation: ERROR: null value in column "created_at" of relation "products" violates not-null constraint (Sequel::NotNullConstraintViolation)
DETAIL: Failing row contains (3, No timestamps, 1, null, null).Cause: Rails’ t.timestamps creates created_at and updated_at as NOT NULL, and Active Record fills them on every save. Sequel’s dataset insert sends exactly the columns you give it, so this raises:
DB[:products].insert(name: "No timestamps", price: 1)Fix: Pass both timestamps in the insert, as the check.rb script does, or write through a Sequel::Model with the timestamps plugin; update_on_create: true makes it fill updated_at on insert as well:
class SequelProduct < Sequel::Model(:products)
plugin :timestamps, update_on_create: true
end
SequelProduct.create(name: "Stamped", price: 1) # fills created_at and updated_atReplacing Active Record with sequel-rails
sequel-rails (1.2.4 in the container; it depends on railties 4.0 or newer and on any sequel from 3.28 through 5.x) swaps Active Record out entirely. Generate the app without Active Record, add the gem and the pg driver, and write a config/database.yml with a postgresql adapter entry (the generator skips that file when Active Record is skipped). The familiar commands then work:
rails new demo --skip-active-record
cd demo
bundle add sequel-rails pg
bin/rails db:create
bin/rails generate model Widget name:string
bin/rails db:migrateThe generator writes a Sequel migration and a Sequel::Model class:
# db/migrate/20260910060437_create_widgets.rb
Sequel.migration do
change do
create_table :widgets do
primary_key :id
String :name
end
end
end# app/models/widget.rb
class Widget < Sequel::Model
endbin/rails db:migrate created widgets plus a schema_migrations table keyed by filename, and Widget.create(name: "Gear") returned a Sequel::Model instance with ActiveRecord never loaded. The gem’s README says Rails 7 needs rails sequel:migrate instead of the db:* tasks; on Rails 8.1.3.1 the db:* commands worked and bin/rails sequel:migrate was an unrecognized command. bin/rake sequel:migrate fails the same way (Don't know how to build task 'sequel:migrate'), so on Rails 8.1 there are no sequel:* tasks to fall back on.
Whichever library ends up running your queries, the slow one is what you will be chasing in production. AppSignal instruments both with no extra setup: Active Record queries arrive as sql.active_record events through the Rails integration, and Sequel queries as sql.sequel events through the Sequel integration, so a slow join shows up in the request timeline no matter which ORM built it. One caveat from the Sequel docs: with sequel-rails, both gems instrument migrations, so disable AppSignal’s Sequel instrumentation if you see doubled events while migrating.
Common Errors When Moving Between Active Record and Sequel
Four errors that come up when code written for one library runs against the other, each with its cause and fix.
No database associated with Sequel::Model: have you called Sequel.connect or Sequel::Model.db= ? (Sequel::Error)
/usr/local/bundle/gems/sequel-5.108.0/lib/sequel/model/base.rb:357:in 'Sequel::Model::ClassMethods#db': No database associated with Sequel::Model: have you called Sequel.connect or Sequel::Model.db= ? (Sequel::Error)Cause: A Sequel::Model subclass needs a connection at class-definition time, because it reads the table’s columns right then. This is what the old version of this post’s Sequel snippets raise when run in the order printed, with the model files before any Sequel.connect:
require "sequel"
class Product < Sequel::Model
one_to_many :orders
endFix: Connect before any model file loads (DB = Sequel.connect(...) at the top of your boot sequence, or in config/initializers/sequel.rb in Rails), or hand the model its dataset explicitly with Sequel::Model(DB[:products]). Active Record has no equivalent trap because it connects lazily on first use.
undefined method 'update' for an instance of Array (NoMethodError) from Sequel’s find
err_find_array.rb:13:in '<main>': undefined method 'update' for an instance of Array (NoMethodError)
invoice1.update(status: 'Sent') # raises NoMethodError
^^^^^^^Cause: Model.find in Sequel is an alias for first, and first with an integer argument means “the first N rows”, so Invoice.find(1) returns a one-element array:
invoice1 = Invoice.find(1)
invoice1.class # => Array
invoice1.update(status: 'Sent') # raises NoMethodErrorFix: Look rows up by primary key with [] or with_pk, and by condition with first and a hash:
Invoice[1].class # => Invoice
Invoice.with_pk(1).class # => Invoice
Invoice.first(status: 'Draft').class # => InvoiceCouldn't find Invoice with 'id'=999 (ActiveRecord::RecordNotFound) vs Sequel::NoMatchingRow
/usr/local/bundle/gems/activerecord-8.1.3.1/lib/active_record/core.rb:279:in 'ActiveRecord::Core::ClassMethods#find': Couldn't find Invoice with 'id'=999 (ActiveRecord::RecordNotFound)/usr/local/bundle/gems/sequel-5.108.0/lib/sequel/model/base.rb:717:in 'Sequel::Model::ClassMethods#with_pk!': Sequel::NoMatchingRow (Sequel::NoMatchingRow)Cause: The libraries default in opposite directions on a miss. Active Record’s find raises RecordNotFound (which Rails turns into a 404) and find_by returns nil; Sequel’s [] and with_pk return nil, and only the bang forms raise:
Invoice.find(999) # raises ActiveRecord::RecordNotFound
Invoice.find_by(id: 999) # => nilInvoice[999] # => nil
Invoice.with_pk(999) # => nil
Invoice.with_pk!(999) # raises Sequel::NoMatchingRowFix: When porting, decide which behavior each call site wants: with_pk! and first! where Active Record code relied on the raise, find_by where Sequel code relied on nil. The Sequel::NoMatchingRow message is nothing but the class name, so rescue it by class rather than matching text. (Active Record’s find_by! reports the condition instead of the key: Couldn't find Invoice with [WHERE "invoices"."status" = $1].)
PG::UndefinedTable: ERROR: relation "nope" does not exist in ActiveRecord::StatementInvalid and Sequel::DatabaseError
PG::UndefinedTable: ERROR: relation "nope" does not exist (ActiveRecord::StatementInvalid)
LINE 1: SELECT * FROM nope
^PG::UndefinedTable: ERROR: relation "nope" does not exist (Sequel::DatabaseError)
LINE 1: SELECT * FROM "nope"
^Cause: The database rejected the statement, usually because a migration has not run or a table name is misspelled. The driver error is identical; the wrapper class is the library’s:
ActiveRecord::Base.lease_connection.execute("SELECT * FROM nope") # raises ActiveRecord::StatementInvalidDB[:nope].all # raises Sequel::DatabaseErrorFix: Run migrations, then check the name and quoting (Sequel quotes identifiers, so the "nope" in its message is the literal table name). To branch on the database’s own error class, unwrap it: Active Record exposes it as cause, Sequel as wrapped_exception (and as cause too):
err.cause.class # => PG::UndefinedTableerr.wrapped_exception.class # => PG::UndefinedTableOn SQLite the text changes and the sqlite3 gem appends the statement after a colon and a line break: SQLite3::SQLException: no such table: nope: followed by SELECT * FROM nope from Active Record, or the same statement with backtick-quoted identifiers from Sequel. A Sequel::Model subclass whose table is missing raises the same Sequel::DatabaseError at class-definition time, from the SELECT * FROM "nopes" LIMIT 0 probe it runs to read the schema, because require_valid_table defaults to true.
When To Choose Active Record Vs. Sequel for Ruby
- You are building or maintaining a Rails app → Active Record. Generators, gems, fixtures, and every guide assume it, and the cost of a second ORM is paid by whoever reads the code next. Bring in Sequel through
sequel-activerecord_connectionfor the handful of queries where a dataset is clearer than a relation. - You are writing a Ruby service without Rails, or the work is SQL-first → Sequel. Frozen datasets,
.sqlon everything, expliciteagerversuseager_graph, and plugins you opt into make the generated SQL predictable. - You need replicas or shards from the start → either. Sequel’s
servers:routing needs no switching code for reads; Active Record’sconnects_toandconnected_toare wired into the Rails request cycle. - You are porting between them → re-test nested transactions, primary-key misses, and validation failure modes first; those are the three places the defaults point in opposite directions.
Neither is faster by enough to decide on. In the container (benchmark-ips 2.15.1, PostgreSQL 17.11 in a sibling Docker container, a 12th Gen Intel i9-12900H laptop host), a primary-key lookup ran 9,800 iterations per second in Sequel against 6,000 in Active Record, with error bars of ±41% and ±18% that benchmark-ips summarized as “difference falls within error.” Repeat runs of the same script on the same host have put that gap anywhere between 1.4× and nearly 3×. The 4,098-row filtered join from the walkthrough ran 104 i/s as Sequel models, 74 i/s as Active Record models, and 59 i/s as plain Sequel hashes. (SELECT * over the join returns both tables’ columns, so the hash form moves the most data.) The ORM is rarely the bottleneck; the query it generates and the round trips it makes are, so measure your own queries before choosing on speed.
Wrapping Up
Active Record and Sequel solve the same problems with the same SQL underneath; the difference is how much of that SQL each one shows you and what it does by default. Active Record is the right call inside Rails, Sequel outside it or whenever you want the query in your hand, and the bridge gems mean you do not have to pick only one.
Until next time, happy coding!
P.S. If you’d like to read Ruby Magic posts as soon as they get off the press, subscribe to our Ruby Magic newsletter and never miss a single post!
Frequently asked questions
- Should I use Active Record or Sequel?
- Use Active Record for a Rails app: it ships with Rails and every gem and generator assumes it. Choose Sequel when you want immutable, SQL-shaped datasets, opt-in plugins, or an ORM outside Rails. Both are maintained and run on Ruby 3.4.
- Can I use Sequel inside a Rails app alongside Active Record?
- Yes. Add the sequel-activerecord_connection gem and create DB = Sequel.postgres(extensions: :activerecord_connection) in an initializer. Sequel then reuses Active Record’s connection and transactions, so a Sequel insert inside an Active Record transaction rolls back with it. Verified on Rails 8.1.3.1.
- Is Sequel faster than Active Record?
- Sometimes, and rarely enough to decide on. In a benchmark on PostgreSQL 17, a primary-key lookup ran faster in Sequel by a margin that benchmark-ips reported as within error, while a 4,000-row join landed within 1.5x of Active Record either way. Measure your own queries before switching.
- What is the difference between eager and eager_graph in Sequel?
- eager loads an association with one extra query per association, like Active Record’s preload, and cannot filter on the associated table. eager_graph uses a single LEFT OUTER JOIN, like eager_load, so you can add where conditions on the joined table’s columns. Active Record’s includes picks between the two for you.
- Why does Sequel raise No database associated with Sequel::Model?
- A Sequel::Model subclass reads its table schema the moment the class is defined, so Sequel needs a connection first. Call DB = Sequel.connect(...) before any model file loads, or pass the dataset explicitly with Sequel::Model(DB[:products]). In Rails, put the connection in an initializer.
Published , Updated
Wondering what you can do next?
- Subscribe to our Ruby Magic newsletter and never miss an article again.
- Start monitoring your Ruby app with AppSignal.
- Share this article on social media

Aestimo Kirina
Our guest author Aestimo is a full-stack developer, tech writer/author and SaaS entrepreneur.
All articles by Aestimo KirinaBecome our next author!
AppSignal monitors your apps
AppSignal provides insights for Ruby, Rails, Elixir, Phoenix, Node.js, Express and many other frameworks and libraries. We are located in beautiful Amsterdam. We love stroopwafels. If you do too, let us know. We might send you some!


