ActiveRecord vs Sequel
ActiveRecord, the ORM built into Rails, makes it easy to work with relational databases by handling most tasks automatically. Sequel takes a different path, focusing on SQL transparency and giving developers more control for performance tuning.
This comparison looks at how each library handles queries, schema changes, and model design to help you choose the best fit for your application’s data layer.
What is ActiveRecord?
ActiveRecord implements the Active Record pattern where model objects carry both data and behavior. It abstracts SQL behind Ruby methods that feel natural to Rails developers.
The library assumes convention over configuration. A User
model automatically maps to a users
table, and methods like User.find(1)
generate appropriate SQL without explicit query writing. This approach reduces boilerplate code in typical web applications.
ActiveRecord ships as part of Rails but works independently. Sinatra and Hanami applications can use ActiveRecord for database access, though the tight Rails integration means you'll encounter Rails conventions even outside the framework.
What is Sequel?
The library separates dataset operations from model behavior. You construct queries using Ruby methods that closely mirror SQL structure, making it clear what database operations will execute. This transparency helps when optimizing queries or debugging performance issues.
Sequel runs independently of any web framework. The library's standalone nature means it doesn't assume Rails conventions or impose framework-specific patterns. This flexibility makes Sequel suitable for background jobs, data processing scripts, and non-web applications where ActiveRecord's assumptions don't fit.
Quick Comparison
Feature | ActiveRecord | Sequel |
---|---|---|
Design Philosophy | Convention over configuration | Explicit query construction |
Primary Pattern | Active Record | Data Mapper (optional) |
Framework Coupling | Rails (can be used independently) | Framework-agnostic |
Query Interface | Model methods and scopes | Dataset chaining |
Raw SQL Visibility | Abstracted (requires .to_sql ) |
Transparent |
Performance Overhead | Higher (more abstraction layers) | Lower (minimal abstraction) |
Migration System | TimestampedMigrations with DSL | Reversible migrations with SQL fallback |
Plugin System | Rails concerns and gems | Built-in plugin architecture |
Association Loading | Eager loading via includes |
Eager loading via eager |
Database Support | PostgreSQL, MySQL, SQLite, others | PostgreSQL, MySQL, SQLite, others (more databases) |
Installation and setup
ActiveRecord typically comes with Rails, but you can install it separately:
gem install activerecord
For a standalone application, establish a database connection:
require 'active_record'
ActiveRecord::Base.establish_connection(
adapter: 'postgresql',
host: 'localhost',
database: 'myapp_development',
username: 'postgres',
password: 'password'
)
Rails applications configure this through config/database.yml
:
development:
adapter: postgresql
host: localhost
database: myapp_development
username: postgres
password: password
The configuration loads automatically when Rails starts, connecting all models to the database without additional setup.
Sequel requires explicit installation:
gem install sequel
Connect to a database using a connection string or hash:
require 'sequel'
# Connection string
DB = Sequel.connect('postgres://postgres:password@localhost/myapp_development')
# Or configuration hash
DB = Sequel.connect(
adapter: 'postgres',
host: 'localhost',
database: 'myapp_development',
user: 'postgres',
password: 'password'
)
The connection object (DB
) provides access to all database operations. Unlike ActiveRecord's global connection, Sequel encourages passing this object explicitly or storing it as a constant.
Defining models
ActiveRecord models inherit from ActiveRecord::Base
and follow Rails naming conventions:
class User < ActiveRecord::Base
# Table name automatically inferred as 'users'
# Primary key automatically assumed as 'id'
end
# The model is immediately usable
user = User.find(1)
user.email = 'new@example.com'
user.save
The inheritance brings hundreds of methods including finders, validations, callbacks, and associations. These methods assume standard Rails conventions about table names, primary keys, and timestamps.
You can override conventions when needed:
class Person < ActiveRecord::Base
self.table_name = 'people_table'
self.primary_key = 'person_id'
end
Sequel separates the model layer from database access. You can query tables directly without defining models:
# Query without a model
users = DB[:users].where(active: true).all
# Each row returns as a hash
users.first[:email] # => "user@example.com"
For model behavior, inherit from Sequel::Model
:
class User < Sequel::Model
# Table name inferred from class name
# Can be explicitly set with set_dataset :users
end
# Usage similar to ActiveRecord
user = User[1] # Find by primary key
user.email = 'new@example.com'
user.save
Sequel models provide less functionality by default. Features like validations and associations require explicit plugin loading:
class User < Sequel::Model
plugin :validation_helpers
plugin :timestamps
def validate
super
validates_presence [:email, :username]
validates_unique :email
end
end
Writing queries
ActiveRecord generates queries through model methods and scopes:
# Simple queries
User.where(active: true)
User.where('created_at > ?', 1.week.ago)
User.order(created_at: :desc).limit(10)
# Chaining conditions
User.where(active: true)
.where('created_at > ?', 1.week.ago)
.order(created_at: :desc)
# Scopes for reusable query fragments
class User < ActiveRecord::Base
scope :active, -> { where(active: true) }
scope :recent, -> { where('created_at > ?', 1.week.ago) }
end
User.active.recent.order(created_at: :desc)
The query methods return relation objects that lazily execute SQL. You can chain multiple conditions before the database query runs, typically when you iterate results or call methods like .to_a
or .count
.
To see the generated SQL:
User.where(active: true).to_sql
# => "SELECT \"users\".* FROM \"users\" WHERE \"users\".\"active\" = TRUE"
Sequel constructs queries through dataset chaining with syntax closer to SQL:
# Simple queries
DB[:users].where(active: true)
DB[:users].where(Sequel.lit('created_at > ?', Time.now - 7*24*60*60))
DB[:users].order(Sequel.desc(:created_at)).limit(10)
# Chaining conditions
DB[:users]
.where(active: true)
.where { created_at > Time.now - 7*24*60*60 }
.order(Sequel.desc(:created_at))
# Model-based queries
User.where(active: true)
.where { created_at > Time.now - 7*24*60*60 }
.order(Sequel.desc(:created_at))
Sequel's virtual row blocks provide SQL-like syntax:
# Complex conditions with virtual row blocks
User.where { (status == 'active') & (created_at > Time.now - 30*24*60*60) }
User.where { name.like('%john%') }
User.where { age > 18 }
The SQL remains visible throughout query construction. Calling .sql
shows the exact query:
User.where(active: true).sql
# => "SELECT * FROM \"users\" WHERE (\"active\" IS TRUE)"
Handling associations
ActiveRecord declares associations using class methods in the model:
class User < ActiveRecord::Base
has_many :posts
has_many :comments, through: :posts
belongs_to :organization
end
class Post < ActiveRecord::Base
belongs_to :user
has_many :comments
end
class Comment < ActiveRecord::Base
belongs_to :post
belongs_to :user
end
These declarations create methods for navigating relationships:
user = User.find(1)
user.posts # Returns all posts for this user
user.posts.create(title: 'New Post') # Creates associated post
user.comments # Returns comments through posts
ActiveRecord lazy-loads associations by default. Accessing user.posts
executes a separate query for posts. This causes N+1 query problems when iterating collections:
# N+1 problem: One query for users, then one query per user for posts
users = User.all
users.each do |user|
puts user.posts.count # Separate query for each user
end
Solve this with eager loading:
# Two queries total: One for users, one for all posts
users = User.includes(:posts)
users.each do |user|
puts user.posts.count # Uses preloaded data
end
Sequel defines associations similarly but with different syntax:
class User < Sequel::Model
one_to_many :posts
many_to_many :comments, join_table: :posts
many_to_one :organization
end
class Post < Sequel::Model
many_to_one :user
one_to_many :comments
end
class Comment < Sequel::Model
many_to_one :post
many_to_one :user
end
Association methods work like ActiveRecord:
user = User[1]
user.posts # Returns all posts
user.add_post(title: 'New Post') # Creates associated post
user.comments # Returns comments through posts
Sequel also lazy-loads by default but provides eager loading:
# Eager load with .eager
users = User.eager(:posts).all
users.each do |user|
puts user.posts.count # Uses preloaded data
end
# Eager load multiple associations
User.eager(:posts, :organization).all
# Nested eager loading
User.eager(posts: :comments).all
Sequel's eager loading tends to generate fewer queries than ActiveRecord's includes
in complex scenarios, especially with nested associations.
Migrations and schema management
ActiveRecord migrations use a Ruby DSL that abstracts SQL:
class CreateUsers < ActiveRecord::Migration[7.0]
def change
create_table :users do |t|
t.string :email, null: false
t.string :username, null: false
t.boolean :active, default: true
t.timestamps
end
add_index :users, :email, unique: true
end
end
The change
method automatically reverses for rollbacks. Rails determines how to undo operations like create_table
(by dropping the table) and add_column
(by removing the column).
For operations that can't automatically reverse, use up
and down
:
class UpdateUserData < ActiveRecord::Migration[7.0]
def up
execute "UPDATE users SET status = 'active' WHERE last_login > NOW() - INTERVAL '30 days'"
end
def down
execute "UPDATE users SET status = 'inactive'"
end
end
Run migrations with Rake tasks:
rails db:migrate
rails db:rollback
rails db:migrate:status
Sequel migrations support both Ruby DSL and raw SQL:
Sequel.migration do
change do
create_table(:users) do
primary_key :id
String :email, null: false
String :username, null: false
TrueClass :active, default: true
DateTime :created_at
DateTime :updated_at
index :email, unique: true
end
end
end
Like ActiveRecord, change
blocks automatically reverse. For explicit control:
Sequel.migration do
up do
run "UPDATE users SET status = 'active' WHERE last_login > NOW() - INTERVAL '30 days'"
end
down do
run "UPDATE users SET status = 'inactive'"
end
end
Run migrations programmatically:
require 'sequel'
DB = Sequel.connect('postgres://localhost/myapp')
Sequel.extension :migration
Sequel::Migrator.run(DB, 'db/migrations')
Sequel's migration system works identically whether you use Rails, Sinatra, or standalone scripts. The same migration files run in any Ruby application.
Validations and callbacks
ActiveRecord includes validations in the model:
class User < ActiveRecord::Base
validates :email, presence: true, uniqueness: true
validates :username, presence: true, length: { minimum: 3 }
validates :age, numericality: { greater_than: 0 }, allow_nil: true
validate :email_format
private
def email_format
unless email =~ /\A[\w+\-.]+@[a-z\d\-]+(\.[a-z\d\-]+)*\.[a-z]+\z/i
errors.add(:email, "must be a valid email address")
end
end
end
Validations run automatically before saving:
user = User.new(email: 'invalid')
user.save # => false
user.errors.full_messages # => ["Username can't be blank", "Email must be a valid email address"]
# Bypass validations if needed
user.save(validate: false)
ActiveRecord provides extensive callback hooks:
class User < ActiveRecord::Base
before_validation :normalize_email
before_save :encrypt_password
after_create :send_welcome_email
after_commit :notify_admin, on: :create
private
def normalize_email
self.email = email.downcase.strip if email.present?
end
def encrypt_password
self.password_digest = BCrypt::Password.create(password) if password.present?
end
def send_welcome_email
UserMailer.welcome(self).deliver_later
end
end
These callbacks execute automatically at specific points in the object lifecycle, keeping related logic centralized in the model.
Sequel requires loading the validation plugin:
class User < Sequel::Model
plugin :validation_helpers
def validate
super
validates_presence [:email, :username]
validates_unique :email
validates_min_length 3, :username
validates_format /\A[\w+\-.]+@[a-z\d\-]+(\.[a-z\d\-]+)*\.[a-z]+\z/i, :email
end
end
Validations run before saving:
user = User.new(email: 'invalid')
user.valid? # => false
user.errors # => {:username=>["is not present"], :email=>["is invalid"]}
user.save # Raises Sequel::ValidationFailed
# Save without validations
user.save(validate: false)
Sequel provides callback hooks through plugins:
class User < Sequel::Model
plugin :validation_helpers
plugin :timestamps
plugin :hook_class_methods
def before_validation
self.email = email.downcase.strip if email
super
end
def before_create
self.password_digest = BCrypt::Password.create(password) if password
super
end
def after_create
super
send_welcome_email
end
private
def send_welcome_email
# Email logic
end
end
The explicit super
calls give you control over callback execution order. Forgetting super
can prevent parent class callbacks from running, which makes debugging easier but requires attention.
Working with JSON and PostgreSQL features
ActiveRecord added strong PostgreSQL support in recent versions:
class CreateEvents < ActiveRecord::Migration[7.0]
def change
create_table :events do |t|
t.jsonb :data, default: {}
t.inet :ip_address
t.tsrange :duration
t.timestamps
end
add_index :events, :data, using: :gin
end
end
Query JSONB columns using PostgreSQL operators:
# Find events with specific JSON data
Event.where("data->>'type' = ?", 'click')
Event.where("data @> ?", { user_id: 123 }.to_json)
# Use ActiveRecord's newer syntax
Event.where("data @> :query", query: { user_id: 123 }.to_json)
Access JSON data through attribute methods:
event = Event.first
event.data # => {"type"=>"click", "user_id"=>123}
event.data['type'] # => "click"
# Modify JSON data
event.data['timestamp'] = Time.now.to_i
event.save
Sequel handles PostgreSQL features through extensions:
Sequel.extension :pg_json
DB.extension :pg_json
class CreateEvents < Sequel::Migration
def change
create_table(:events) do
primary_key :id
jsonb :data, default: '{}'
inet :ip_address
tsrange :duration
DateTime :created_at
DateTime :updated_at
index :data, type: :gin
end
end
end
Query JSONB with native PostgreSQL operator support:
# JSON queries using Sequel's PostgreSQL extension
Event.where(Sequel.pg_jsonb(:data).get_text('type') => 'click')
Event.where(Sequel.pg_jsonb(:data).contains(user_id: 123))
# Raw SQL for complex queries
Event.where(Sequel.lit("data @> ?", {user_id: 123}.to_json))
Access JSON data directly:
event = Event.first
event.data # => {"type"=>"click", "user_id"=>123}
# Modify JSON data
event.data['timestamp'] = Time.now.to_i
event.save
Sequel's PostgreSQL extension provides methods for every PostgreSQL operator, making advanced features accessible without raw SQL strings.
Transaction handling
ActiveRecord wraps operations in transactions using blocks:
ActiveRecord::Base.transaction do
user = User.create!(email: 'user@example.com')
profile = Profile.create!(user: user, bio: 'Hello')
user.update!(confirmed: true)
end
Raising an exception rolls back all operations in the transaction. ActiveRecord automatically rolls back when save methods with !
fail:
ActiveRecord::Base.transaction do
user = User.create!(email: 'user@example.com')
# This will raise and rollback the transaction
profile = Profile.create!(user: user, bio: nil) # Fails validation
end
You can manually trigger rollbacks:
ActiveRecord::Base.transaction do
user = User.create!(email: 'user@example.com')
if external_api_call_fails?
raise ActiveRecord::Rollback # Silently rolls back
end
end
Nested transactions use savepoints:
User.transaction do
user = User.create!(email: 'user@example.com')
Profile.transaction do
profile = Profile.create!(user: user)
raise ActiveRecord::Rollback # Only rolls back profile creation
end
# User creation remains
end
Sequel handles transactions similarly:
DB.transaction do
user = User.create(email: 'user@example.com')
profile = Profile.create(user_id: user.id, bio: 'Hello')
user.update(confirmed: true)
end
Exceptions automatically rollback:
DB.transaction do
user = User.create(email: 'user@example.com')
# Validation failure raises exception and rolls back
profile = Profile.create(user_id: user.id, bio: nil)
rescue Sequel::ValidationFailed => e
# Transaction already rolled back
puts "Failed: #{e.message}"
end
Manual rollback:
DB.transaction do
user = User.create(email: 'user@example.com')
raise Sequel::Rollback if external_api_call_fails?
end
Sequel supports savepoints for nested transactions:
DB.transaction do
user = User.create(email: 'user@example.com')
DB.transaction(savepoint: true) do
profile = Profile.create(user_id: user.id)
raise Sequel::Rollback # Only rolls back profile
end
# User creation remains
end
Both libraries handle transactions reliably, with similar APIs and behavior.
Final thoughts
Both ActiveRecord and Sequel give Ruby applications reliable ways to work with databases. ActiveRecord is tightly integrated with Rails and comes with a large ecosystem, which makes it the practical choice in most Rails projects. Its conventions cut down on boilerplate and keep codebases consistent, which is especially valuable for teams.
Sequel, in contrast, appeals to those who want more control. It favors explicit query building and strong performance, and because it is not tied to Rails, it works well in standalone apps, background jobs, or projects where Rails conventions do not fit. Its ecosystem is smaller and may require more custom code, but the trade-off is flexibility and speed.