Ruby

Active Record vs Sequel: Choosing a Ruby ORM

Active Record vs Sequel: Choosing a Ruby ORM

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.1Sequel 5.108
Where it livesBundled with Rails; config/database.ymlStandalone gem; DB = Sequel.connect(url)
Query objectActiveRecord::Relation, lazy, to_sqlSequel::Dataset, lazy and frozen, .sql; .all returns hashes, models via Sequel::Model
ModelsActiveRecord::Base, has_many/belongs_to, concernsSequel::Model, one_to_many/many_to_one, opt-in plugins
Eager loadingincludes (picks preload or eager_load), preload, eager_loadeager (extra query per association) or eager_graph (one LEFT OUTER JOIN; filter on it)
Row lockslock → FOR UPDATE, with_lock; lock("FOR SHARE")for_update, for_share, lock_style, Model#lock!; Dataset#lock(:exclusive) → LOCK TABLE
Optimistic lockingAutomatic with a lock_version column → StaleObjectErrorplugin :optimistic_locking + lock_version → NoExistingObject
Nested transactionsInner ActiveRecord::Rollback is swallowed; outer commits unless requires_newInner Sequel::Rollback rolls the whole transaction back unless savepoint: true
Validationsvalidates … → RecordInvalid: Validation failed: Name can't be blankplugin :validation_helpers → ValidationFailed: name is not present
MigrationsActiveRecord::Migration[8.1], schema_migrationsSequel.migration do change do … end end, Sequel::Migrator (schema_info or schema_migrations)
Replicas and shardsconnects_to database:/shards:, connected_to(role:, shard:)servers: { read_only: …, shard_one: … }; SELECTs route to :read_only; .server(:shard_one)
Inside a Rails appThe defaultsequel-activerecord_connection 2.0.1 (shares the Active Record connection) or sequel-rails 1.2.4 (replaces Active Record)
Ruby 3.4 / Rails 8.1Yes (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, .sql works on everything, and plugins load only when you ask for them.
  • Multiple databases from day one → both can. Sequel’s per-dataset .server and automatic read routing are the simpler model; Active Record’s connects_to is 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:

Ruby
require "active_record"
 
ActiveRecord::Base.establish_connection(ENV.fetch("DATABASE_URL"))

The connection pool defaults to five connections when the configuration sets no pool:

Ruby
ActiveRecord::Base.connection_pool.size # => 5

Models subclass ActiveRecord::Base (ApplicationRecord in Rails), infer their table from the class name, and declare associations with has_many and belongs_to:

Ruby
# models/order.rb
class Order < ActiveRecord::Base
  belongs_to :product
end
 
# models/product.rb
class Product < ActiveRecord::Base
  has_many :orders
end

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

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

Ruby
DB.pool.class    # => Sequel::TimedQueueConnectionPool
DB.pool.max_size # => 4

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

Ruby
# models/product.rb
class Product < Sequel::Model
  one_to_many :orders
end
 
# models/order.rb
class Order < Sequel::Model
  many_to_one :product
end

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

Ruby
orders = DB[:orders]
orders.frozen? # => true
 
puts orders.sql
puts orders.where(quantity: 2).sql
puts orders.sql # unchanged: where returned a new dataset
SQL
SELECT * 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:

Ruby
product_orders_under_20 = Order.joins(:product).where('products.price < ?', 20)
 
puts product_orders_under_20.to_sql
SQL
SELECT "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:

Ruby
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 # => Order

The string condition works, but the hash form quotes the column for you and accepts a beginless range for the comparison:

Ruby
puts Order.joins(:product).where(products: { price: ...20 }).to_sql
SQL
SELECT "orders".* FROM "orders" INNER JOIN "products" ON "products"."id" = "orders"."product_id" WHERE "products"."price" < 20.0

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

Ruby
product_orders_under_20 = DB[:orders].join(:products, id: :product_id).where { price < 20 }
 
puts product_orders_under_20.sql
SQL
SELECT * 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:

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

Ruby
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 # => Order
SQL
SELECT "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:

Ruby
invoice1 = Invoice.find(1)
invoice2 = Invoice.find(1)
 
invoice1.total_amount = 100
invoice1.save # => true
 
invoice2.total_amount = 500
invoice2.save # raises ActiveRecord::StaleObjectError

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

SQL
UPDATE "invoices" SET "total_amount" = $1, "lock_version" = $2 WHERE "invoices"."id" = $3 AND "invoices"."lock_version" = $4

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

Ruby
Invoice.transaction do
  invoice = Invoice.lock.find(1)
  invoice.update!(status: "Sent")
end
SQL
BEGIN
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
COMMIT

with_lock does the same for a record you already hold: it opens a transaction, reloads the row with FOR UPDATE, and yields:

Ruby
invoice = Invoice.find(1)
 
invoice.with_lock do
  invoice.update!(status: "Paid")
end

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

Ruby
puts Invoice.lock("FOR SHARE").where(id: 1).to_sql
SQL
SELECT "invoices".* FROM "invoices" WHERE "invoices"."id" = 1 FOR SHARE

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

Ruby
# models/invoice.rb
class Invoice < Sequel::Model
  plugin :optimistic_locking
end

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

Ruby
invoice1 = Invoice[1]
invoice2 = Invoice[1]
 
invoice1.update(status: 'Sent') # record will be updated
invoice2.update(status: 'Void') # raises Sequel::NoExistingObject

Sequel logs the version check with the literal values inlined, and wraps each model save in its own transaction:

SQL
BEGIN
UPDATE "invoices" SET "status" = 'Sent', "lock_version" = 1 WHERE (("id" = 1) AND ("lock_version" = 0))
COMMIT

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

Ruby
dataset = DB[:invoices]
invoice1 = dataset.where(id: 1).for_update # a dataset, not a row; runs no SQL yet
 
puts invoice1.sql
SQL
SELECT * FROM "invoices" WHERE ("id" = 1) FOR UPDATE

The working form executes the locked SELECT inside DB.transaction and writes before it commits:

Ruby
DB.transaction do
  invoice = Invoice.where(id: 1).for_update.first
  invoice.update(status: 'Sent')
end
SQL
BEGIN
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))
COMMIT

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

Ruby
puts DB[:invoices].where(id: 1).for_share.sql
puts DB[:invoices].where(id: 1).lock_style('FOR UPDATE NOWAIT').sql
SQL
SELECT * FROM "invoices" WHERE ("id" = 1) FOR SHARE
SELECT * FROM "invoices" WHERE ("id" = 1) FOR UPDATE NOWAIT

Jeremy Evans’ post on pessimistic locking in Sequel explains the design.

Attempted to update a stale object: Invoice. (ActiveRecord::StaleObjectError)

text
/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:

Ruby
begin
  invoice2.save!
rescue ActiveRecord::StaleObjectError
  invoice2.reload
  invoice2.total_amount = 500
  invoice2.save!
end

In 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)

text
/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:

Ruby
begin
  invoice2.update(status: 'Void')
rescue Sequel::NoExistingObject
  invoice2.refresh
  invoice2.update(status: 'Void')
end

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

Ruby
# 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
end

Account.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:

Ruby
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)
end

After the two lookups, the block logs exactly one transaction:

SQL
BEGIN
UPDATE "accounts" SET "account_total" = $1 WHERE "accounts"."id" = $2
UPDATE "accounts" SET "account_total" = $1 WHERE "accounts"."id" = $2
COMMIT

To abandon a transaction without an error reaching the caller, raise ActiveRecord::Rollback. The block swallows it and returns nil:

Ruby
result = Account.transaction do
  a_account.withdraw(50)
  raise ActiveRecord::Rollback
end
 
result # => nil

A nested block needs requires_new: true to get its own savepoint; then an inner rollback undoes only the inner writes:

Ruby
Account.transaction do
  a_account.withdraw(10)
 
  Account.transaction(requires_new: true) do
    b_account.withdraw(1000)
    raise ActiveRecord::Rollback
  end
end
SQL
BEGIN
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
COMMIT

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

Ruby
# 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
end

Transactions belong to the database object, not the model, so the block is DB.transaction:

Ruby
DB.transaction do
  Person.first(name: "A").accounts.first.withdraw(250)
  Person.first(name: "B").accounts.first.deposit(250)
end

Sequel’s log shows the lookups inside the transaction, with every value inlined:

SQL
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)
COMMIT

Sequel::Rollback is the silent rollback, and the block returns nil the same way:

Ruby
result = DB.transaction do
  Person.first(name: "A").accounts.first.withdraw(50)
  raise Sequel::Rollback
end
 
result # => nil

Savepoints are opt-in with savepoint: true, and Sequel names them autopoint_1, autopoint_2, and so on:

Ruby
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
end
SQL
BEGIN
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
COMMIT

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

Ruby
Sequel::Model.use_transactions # => true
 
Invoice[1].update(status: 'Paid')
SQL
BEGIN
UPDATE "invoices" SET "status" = 'Paid', "lock_version" = 1 WHERE (("id" = 1) AND ("lock_version" = 0))
COMMIT

Sequel’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:

Ruby
Account.transaction do
  a_account.withdraw(10)
 
  Account.transaction do
    raise ActiveRecord::Rollback
  end
end
 
a_account.reload.account_total # => 340
SQL
BEGIN
UPDATE "accounts" SET "account_total" = $1 WHERE "accounts"."id" = $2
COMMIT

Sequel without savepoint: true treats the inner block as part of the outer one too, but its Rollback rolls the whole transaction back:

Ruby
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 # => 350
SQL
BEGIN
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)
ROLLBACK

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

Ruby
Product.includes(:orders).to_a
SQL
SELECT "products".* FROM "products"
SELECT "orders".* FROM "orders" WHERE "orders"."product_id" IN ($1, $2)
Ruby
Product.includes(:orders).where(orders: { quantity: 2 }).to_a
SQL
SELECT "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" = $1

preload and eager_load pick one strategy explicitly, and strict_loading turns any lazy association load into an error:

Ruby
Product.strict_loading.first.orders.to_a # raises ActiveRecord::StrictLoadingViolationError
text
`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:

Ruby
Product.eager(:orders).all
SQL
SELECT * 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:

Ruby
puts Product.eager_graph(:orders).where(Sequel[:orders][:quantity] => 2).sql
SQL
SELECT "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:

Ruby
Product.eager(:orders).where(Sequel[:orders][:quantity] => 2).all # raises Sequel::DatabaseError
text
Sequel::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:

Ruby
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                 # => false
text
ActiveRecord::RecordInvalid: Validation failed: Name can't be blank

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

Ruby
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) # => nil
text
Sequel::ValidationFailed: name is not present

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

Ruby
class CreateWidgets < ActiveRecord::Migration[8.1]
  def change
    create_table :widgets do |t|
      t.string :name, null: false
      t.timestamps
    end
  end
end

Sequel migrations are Sequel.migration blocks with the same reversible change idea, but column types are Ruby classes and the primary key is explicit:

Ruby
# 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
end

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

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

Ruby
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; end
Ruby
ActiveRecord::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") }
text
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:

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

Ruby
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_one

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

Shell
bundle add sequel-activerecord_connection
Ruby
# 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:

Ruby
# 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}"
text
$ 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 5

Sequel 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)

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

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

Ruby
class SequelProduct < Sequel::Model(:products)
  plugin :timestamps, update_on_create: true
end
 
SequelProduct.create(name: "Stamped", price: 1) # fills created_at and updated_at

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

Shell
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:migrate

The generator writes a Sequel migration and a Sequel::Model class:

Ruby
# db/migrate/20260910060437_create_widgets.rb
Sequel.migration do
  change do
 
    create_table :widgets do
      primary_key :id
      String :name
    end
 
  end
end
Ruby
# app/models/widget.rb
class Widget < Sequel::Model
 
end

bin/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)

text
/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:

Ruby
require "sequel"
 
class Product < Sequel::Model
  one_to_many :orders
end

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

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

Ruby
invoice1 = Invoice.find(1)
invoice1.class # => Array
 
invoice1.update(status: 'Sent') # raises NoMethodError

Fix: Look rows up by primary key with [] or with_pk, and by condition with first and a hash:

Ruby
Invoice[1].class                     # => Invoice
Invoice.with_pk(1).class             # => Invoice
Invoice.first(status: 'Draft').class # => Invoice

Couldn't find Invoice with 'id'=999 (ActiveRecord::RecordNotFound) vs Sequel::NoMatchingRow

text
/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)
text
/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:

Ruby
Invoice.find(999)        # raises ActiveRecord::RecordNotFound
Invoice.find_by(id: 999) # => nil
Ruby
Invoice[999]          # => nil
Invoice.with_pk(999)  # => nil
Invoice.with_pk!(999) # raises Sequel::NoMatchingRow

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

text
PG::UndefinedTable: ERROR:  relation "nope" does not exist (ActiveRecord::StatementInvalid)
LINE 1: SELECT * FROM nope
                      ^
text
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:

Ruby
ActiveRecord::Base.lease_connection.execute("SELECT * FROM nope") # raises ActiveRecord::StatementInvalid
Ruby
DB[:nope].all # raises Sequel::DatabaseError

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

Ruby
err.cause.class # => PG::UndefinedTable
Ruby
err.wrapped_exception.class # => PG::UndefinedTable

On 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_connection for 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, .sql on everything, explicit eager versus eager_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’s connects_to and connected_to are 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?

  • Share this article on social media
Aestimo Kirina

Aestimo Kirina

Our guest author Aestimo is a full-stack developer, tech writer/author and SaaS entrepreneur.

All articles by Aestimo Kirina

Become our next author!

Find out more
$appsignal install

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!

Discover AppSignal