All query methods return an ActiveRecord::Relation (lazy) until enumerated. Chaining is free.
User.find(1) # raises if not found
User.find_by(email: "x@y.com") # nil if not found
User.find_by!(email: "x@y.com")# raises if not found
User.first / .last / .take
User.find([1, 2, 3]) # returns array, raises on missing
User.find_or_initialize_by(slug: "foo")
User.find_or_create_by(slug: "foo")User.where(active: true)
User.where(role: ["admin", "mod"]) # IN (...)
User.where(created_at: 1.week.ago..) # open-ended range (Rails 6+)
User.where(created_at: 1.week.ago..Time.now)
User.where.not(role: nil)
User.where.not(role: "guest")
User.where("score > ?", 100) # use ? placeholders
User.where("name ILIKE ?", "%#{q}%") # safe interpolation
User.where(users: { active: true }) # table-qualified.where("name = '#{params[:name]}'") — SQL injection# select returns AR objects with only those attrs loaded
User.select(:id, :email)
# pluck returns plain array — no model instantiation
User.pluck(:email) # ["a@b.com", ...]
User.pluck(:id, :email) # [[1, "a@b.com"], ...]
# pick = pluck.first, single row
User.where(id: 5).pick(:email) # "a@b.com"
# ids shortcut
User.where(active: true).ids # [1, 2, 3]User.count
User.count(:email) # counts non-null emails
User.distinct.count(:role)
Order.sum(:amount)
Order.average(:amount)
Order.minimum(:amount)
Order.maximum(:amount)
Order.group(:status).count # => {"pending"=>5, "paid"=>12}
Order.group(:status).sum(:amount)
Order.having("count(*) > 5").group(:user_id).countUser.order(:name)
User.order(name: :desc)
User.order("name DESC NULLS LAST")
User.reorder(:email) # replaces any prior order
User.unscope(:order) # strips order entirely
User.limit(10).offset(20)
User.in_order_of(:status, ["active","pending","closed"])User.exists?(active: true) # SELECT 1 LIMIT 1
User.where(role: "admin").any? # same as exists?, fires query
User.where(role: "admin").none?# fires query
User.where(role: "admin").one? # fires query
# loaded? check matters:
users = User.all
users.loaded? # false — iterating triggers load
users.to_a # triggers load
users.loaded? # true — any? / none? / count use in-memory# Never: User.all.each — loads everything into memory
User.find_each(batch_size: 500) { |u| ... } # yields one at a time
User.find_in_batches(batch_size: 500) { |batch| ... } # yields arrays
User.in_batches(of: 500) { |rel| rel.update_all(active: false) }
# Custom ordering / start with find_each:
User.where(active: true).find_each(start: 1000, finish: 5000) { |u| ... }
# Ordering gotcha: find_each ignores .order() — always uses PK ASC
# Use in_batches if you need custom order controlORDER BY id internally# Escape and inject literals safely
User.where("created_at > ?", 1.day.ago)
User.where("role = :role", role: "admin")
# Arbitrary SQL fragments
User.from("users TABLESAMPLE SYSTEM(10)")
User.select("*, rank() OVER (ORDER BY score DESC) as rank")
# Arel (avoid if SQL strings work fine)
t = User.arel_table
User.where(t[:score].gt(100).and(t[:active].eq(true)))
# Named bind params (sanitized)
User.where("name = :name AND role = :role", name: n, role: r)# Subquery via relation
recent = Order.where("created_at > ?", 1.week.ago).select(:user_id)
User.where(id: recent) # WHERE id IN (SELECT user_id FROM ...)
# FROM subquery
subq = Order.group(:user_id).select("user_id, sum(amount) as total")
User.joins("JOIN (#{subq.to_sql}) o ON o.user_id = users.id")
.select("users.*, o.total")
# CTE (Rails 7.1+)
User.with(actives: User.where(active: true))
.joins("JOIN actives ON actives.id = users.id")User.none # returns empty relation, chainable
User.all.merge(scope) # merge two relations
User.unscoped # strips ALL default_scopes
User.readonly # marks records as read-only
User.lock # SELECT ... FOR UPDATE
User.lock("LOCK IN SHARE MODE") # MySQL share lock
User.distinct
User.group(:role).distinct.count(:email)
User.where(...).to_sql # debug: see generated SQL
User.where(...).explain # runs EXPLAIN on queryThe most important tab. These are the patterns that silently wreck production.
# BAD — fires 1 query for posts, then 1 per post for user
posts = Post.all
posts.each { |p| puts p.user.name } # N+1!
# GOOD
posts = Post.includes(:user).all
posts.each { |p| puts p.user.name } # 2 queries total
# ALSO GOOD when filtering on association
Post.joins(:user).where(users: { active: true })
# (don't use includes if you're only filtering, not loading)# Gemfile (development/test only)
gem "bullet"
# config/environments/development.rb
config.after_initialize do
Bullet.enable = true
Bullet.alert = true
Bullet.rails_logger = true
end
# Or: strict_loading to raise in dev/test
# config/application.rb
config.active_record.strict_loading_by_default = true
# Per-query opt-out
Post.strict_loading.includes(:user) # explicit eager load required
Post.strict_loading(false).all # opt outposts = Post.where(active: true)
posts.count # always fires SELECT COUNT(*) — even if already loaded
posts.length # loads all records into memory (to_a), returns array.length
posts.size # smart: COUNT if not loaded, length if already loaded
# Best practice: use .size unless you specifically need fresh DB count
# If the relation is already loaded (posts.loaded? == true), size is free
# Counter cache is better than any of these for has_many counts:
# add_column :users, :posts_count, :integer, default: 0
# has_many :posts, counter_cache: true
user.posts.size # reads posts_count column, no query!| Method | SQL fired | When already loaded | Use when |
|---|---|---|---|
exists? | SELECT 1 LIMIT 1 | still fires query | checking existence cheaply |
any? | SELECT 1 LIMIT 1 | uses in-memory if loaded | general purpose |
none? | SELECT 1 LIMIT 1 | uses in-memory if loaded | general purpose |
empty? | SELECT COUNT(*) | uses in-memory if loaded | avoid, prefer none? |
present? | loads all records | uses in-memory if loaded | never for relations |
blank? | loads all records | uses in-memory if loaded | never for relations |
present? or blank? on an AR relation — they call to_a first, loading all records.# pluck: returns plain Ruby array, no AR objects instantiated
User.where(active: true).pluck(:email)
# SELECT "users"."email" FROM "users" WHERE active = true
# select: returns AR objects (with only those attrs loaded)
User.where(active: true).select(:id, :email)
# Useful when you need an AR relation (e.g., for further chaining)
# map: loads full AR objects first, THEN maps
User.where(active: true).map(&:email)
# SELECT * FROM users — wasteful if you only need email
# Rule of thumb:
# - Need plain data? → pluck
# - Need AR objects? → select
# - Transforming loaded data? → map (fine if already loaded)| Method | SQL | Callbacks | Validations | Associations |
|---|---|---|---|---|
update_all | UPDATE ... WHERE | skipped | skipped | not touched |
delete_all | DELETE ... WHERE | skipped | skipped | not cascaded |
destroy_all | loads + DELETE per row | runs | n/a | cascaded |
# update_all — single SQL, no callbacks, no updated_at by default
Post.where(draft: true).update_all(published: false)
# updated_at NOT touched automatically — pass it explicitly:
Post.where(draft: true).update_all(published: false, updated_at: Time.current)
# delete_all — no dependent: :destroy cascade, no callbacks
Post.where(user_id: 5).delete_all # leaves orphaned comments!
# destroy_all — fires one DELETE per record (slow for large sets)
Post.where(user_id: 5).destroy_all # runs callbacks, cascades depsupdate_all does NOT auto-update updated_at. You must include it manually.user.destroy # loads record, runs before_destroy callbacks,
# cascades dependent: :destroy associations, then DELETEs
# returns the (frozen) record or raises on failure
user.delete # raw DELETE WHERE id = ?, no callbacks, no cascade
# returns the count of deleted rows
# Class-level
User.destroy(5) # loads and destroys record with id=5
User.delete(5) # raw delete, no instantiation# default_scope applies to ALL queries including joins, associations
class Post < ApplicationRecord
default_scope { where(published: true) }
end
Post.all # WHERE published = true — expected
Post.unscoped # strips ALL scopes — but use with care!
Post.with_scope # use named scopes instead
# Ordering surprise:
class Post < ApplicationRecord
default_scope { order(:created_at) }
end
Post.order(:title) # ORDER BY created_at, title — NOT just title!
Post.reorder(:title) # ORDER BY title — replaces default order
# default_scope on associations:
user.posts # also applies default_scope — can be surprising
user.posts.unscoped # strips user_id scope too! Use with care
# Generally: avoid default_scope. Use named scopes instead.
scope :published, -> { where(published: true) }unscoped strips ALL scopes including the association constraint.# Raises ActiveRecord::StrictLoadingViolationError on lazy loads
Post.strict_loading.find(1).user.name # raises!
Post.includes(:user).strict_loading.find(1).user.name # fine
# Per-model (Rails 6.1+)
class Post < ApplicationRecord
self.strict_loading_by_default = true
end
# Per-association
has_many :comments, strict_loading: true
# Global (great for test env)
# config/environments/test.rb
config.active_record.strict_loading_by_default = truestrict_loading_by_default in test env to catch N+1s at the source.# Large text/jsonb columns are expensive to deserialize
# If you don't need body, exclude it:
Post.select(:id, :title, :created_at).where(published: true)
# Accessing an unselected attr raises MissingAttributeError
post.body # raises if body wasn't in SELECT
# Computed columns via select
User.select("*, (SELECT COUNT(*) FROM posts WHERE posts.user_id = users.id) as posts_count")
user.posts_count # available as attribute
# Use pluck for pure data extraction — fastest option
Post.where(published: true).pluck(:id, :title)class Comment < ApplicationRecord
belongs_to :post, touch: true # touches post.updated_at on save/destroy
end
# This cascades: saving a comment fires an UPDATE on posts table
# If Post also touches User, that's 3 UPDATEs for 1 comment save
# Can trigger cache invalidation cascades in high-traffic scenarios
# touch: false to opt out for a specific operation:
comment.save(touch: false) # doesn't touch parent
# Bulk touch
Post.where(id: ids).touch_all # Rails 6+ — single UPDATE# includes: use when you WANT to load the association
Post.includes(:user).where(users: { active: true })
# Generates LEFT OUTER JOIN (or 2 queries) — loads user objects
# joins: use when you ONLY want to filter, NOT load the association
Post.joins(:user).where(users: { active: true })
# INNER JOIN — does NOT load user, accessing post.user = N+1!
# Rule:
# - Filter only → joins
# - Filter + load → includes (or eager_load for guaranteed JOIN)Post.where(active: true).to_sql # see the SQL
Post.where(active: true).explain # runs EXPLAIN
Post.where(active: true).explain(:analyze) # EXPLAIN ANALYZE (PG)
# Log all queries in a block:
ActiveRecord::Base.logger = Logger.new(STDOUT)
# Count queries in tests:
query_count = 0
counter = ->(*, **) { query_count += 1 }
ActiveSupport::Notifications.subscribed(counter, "sql.active_record") do
# your code
end
puts query_countuser = User.find(1)
user.name = "New Name"
user.changed? # true
user.name_changed? # true
user.name_was # original value
user.name_change # ["Old Name", "New Name"]
user.changes # {"name" => ["Old", "New"]}
user.save
# After save — use saved_changes, NOT changes (now empty)
user.changes # {} — cleared after save
user.saved_changes # {"name" => ["Old", "New"]}
user.saved_change_to?(:name) # true
user.saved_change_to_name? # true
user.name_before_last_save # "Old Name"
# In after_save callback: use saved_changes
# In before_save callback: use changessave, changes is empty. Use saved_changes in after_save callbacks.Four strategies, each with different SQL behavior. Picking the wrong one is the #1 source of query inefficiency.
| Method | SQL strategy | Loads association? | Filterable in WHERE? | Use when |
|---|---|---|---|---|
preload |
2 separate queries | yes | no | loading large associations, avoids row duplication |
includes |
preload OR LEFT OUTER JOIN (auto-selects based on usage) |
yes | switches to JOIN if filtered | general-purpose eager load |
eager_load |
always LEFT OUTER JOIN | yes | yes (same query) | filtering + loading; guarantee single query |
joins |
INNER JOIN | no | yes | filtering only — do not load association |
Post.preload(:user)
# SELECT * FROM posts
# SELECT * FROM users WHERE id IN (1, 2, 3, ...)
# Cannot use in WHERE:
Post.preload(:user).where(users: { active: true })
# => ActiveRecord::StatementInvalid — can't filter on preloaded tablePost.includes(:user)
# Uses preload (2 queries) by default
Post.includes(:user).where(users: { active: true })
# Detects reference to users table → switches to LEFT OUTER JOIN
# (same as eager_load)
Post.includes(:user).references(:user).where("users.active = true")
# .references forces JOIN mode when using string WHERE clause
# Nested
Post.includes(comments: :author)
Post.includes(:user, comments: [:author, :likes])Post.eager_load(:user)
# SELECT posts.*, users.* FROM posts
# LEFT OUTER JOIN users ON users.id = posts.user_id
# Good for filtering AND loading in one query:
Post.eager_load(:user).where(users: { active: true })
# Watch out: has_many + eager_load → row duplication
# A post with 3 comments = 3 rows; AR dedupes via ID but
# LIMIT/OFFSET math breaks:
Post.eager_load(:comments).limit(5) # may return < 5 posts!eager_load with has_many and LIMIT can return fewer records than expected due to JOIN row duplication.Post.joins(:user).where(users: { active: true })
# INNER JOIN — posts without a user are excluded
# user NOT loaded — accessing post.user triggers a new query!
# LEFT OUTER JOIN manually:
Post.joins("LEFT OUTER JOIN users ON users.id = posts.user_id")
# Joining through:
Post.joins(comments: :author)
# JOIN comments ... JOIN users (authors) ...
# join + select for custom columns
Post.joins(:user).select("posts.*, users.name as author_name")
post.author_name # available as attribute# includes uses JOIN if:
# 1. You filter on the association table in WHERE/HAVING
Post.includes(:user).where(users: { active: true })
# 2. You call .references(:assoc)
Post.includes(:user).references(:user).where("users.active = true")
# includes uses preload (2 queries) if:
# No reference to the association in WHERE/HAVING
Post.includes(:user)
# Force preload explicitly if you're worried about JOIN row duplication:
Post.preload(:user)
# Force JOIN explicitly:
Post.eager_load(:user)# Hash for nested
Post.includes(comments: :author)
Post.includes(comments: [:author, :reactions])
# Array for multiple top-level
Post.includes(:user, :tags, comments: :author)
# With conditions on nested (use joins for filtering):
Post.includes(:comments).where(comments: { approved: true })
# Uses LEFT OUTER JOIN — posts WITH NO approved comments still returned
# (their comments collection will be empty)
# vs INNER JOIN to exclude posts with no approved comments:
Post.joins(:comments).where(comments: { approved: true }).includes(:comments)
# joins filters; includes loads — may issue separate preload queryKey options and non-obvious behavior. Assumes you know the basics.
has_many :posts # foreign_key: :user_id inferred
has_many :published_posts,
-> { where(published: true) }, # scope lambda
class_name: "Post"
has_many :posts, foreign_key: :author_id
has_many :posts, primary_key: :uuid # non-id primary key
has_many :posts, inverse_of: :user # bidirectional identity
has_many :posts, strict_loading: true
has_many :posts, dependent: :destroy # cascade destroy (slow)
has_many :posts, dependent: :delete_all # cascade SQL delete (fast, no callbacks)
has_many :posts, dependent: :nullify # SET foreign_key = NULL
has_many :posts, dependent: :restrict_with_error # prevent deletion if assoc exists
has_many :posts, dependent: :restrict_with_exceptionclass User < ApplicationRecord
has_many :memberships
has_many :groups, through: :memberships
has_many :posts, through: :groups # multi-hop
end
class Membership < ApplicationRecord
belongs_to :user
belongs_to :group
end
# source: needed when name doesn't match
has_many :admins, through: :memberships,
source: :user,
source_type: "User" # for polymorphic throughbelongs_to :user # required by default (Rails 5+)
belongs_to :user, optional: true # allow nil
belongs_to :author, class_name: "User"
belongs_to :user, counter_cache: true # maintains user.posts_count
belongs_to :user, counter_cache: :articles_count # custom column name
belongs_to :post, touch: true # updates post.updated_at
belongs_to :category, touch: :category_updated_at # custom timestamp col
belongs_to :user, inverse_of: :posts # in-memory identityclass Comment < ApplicationRecord
belongs_to :commentable, polymorphic: true
end
class Post < ApplicationRecord
has_many :comments, as: :commentable
end
class Video < ApplicationRecord
has_many :comments, as: :commentable
end
# columns: commentable_id (integer), commentable_type (string)
comment.commentable # returns Post or Video instance
comment.commentable_type # "Post"
# Gotcha: can't JOIN across polymorphic — no way to JOIN both tables
# Use includes for loading, avoid JOINs on polymorphic associationscommentable_type values.has_one :profile # uses user_id on profiles table
has_one :profile, dependent: :destroy
has_one :latest_post,
-> { order(created_at: :desc) }, # scope to pick which one
class_name: "Post"
# Build vs create:
user.build_profile(bio: "...") # builds in memory, not saved
user.create_profile(bio: "...") # saves immediately
user.profile ||= Profile.new # equivalent to build if nil# Appending with <<
user.posts << Post.new(title: "x") # saves immediately if user is persisted
# build vs create on collection
user.posts.build(title: "x") # in-memory, not saved
user.posts.create(title: "x") # saves immediately
# Caching — AR caches association results
user.posts.count # SELECT COUNT(*)
user.posts.count # SELECT COUNT(*) again (not cached)
user.posts.size # uses cached length if loaded
# Force reload
user.posts(true) # deprecated — use reload
user.association(:posts).reload
user.reload.posts # reloads full record
# inverse_of prevents creating extra objects in memory
# Without it: post.user.object_id != user.object_id (two User instances)Named scopes, scope composition, and the default_scope trap.
class Post < ApplicationRecord
scope :published, -> { where(published: true) }
scope :recent, -> { order(created_at: :desc) }
scope :by_author, ->(user) { where(author: user) }
scope :created_after, ->(date) { where("created_at > ?", date) }
# Scope with includes
scope :with_author, -> { includes(:author) }
# Never use default_scope — use named scopes instead
end
Post.published.recent.by_author(current_user)
Post.published.created_after(1.week.ago)# Scopes chain with AND by default
Post.published.recent # WHERE published = true ORDER BY created_at DESC
# Merging scopes from another model (great with joins)
class Post < ApplicationRecord
scope :by_active_users, -> { joins(:user).merge(User.active) }
end
class User < ApplicationRecord
scope :active, -> { where(active: true) }
end
Post.by_active_users
# SELECT posts.* FROM posts INNER JOIN users ON ... WHERE users.active = true
# OR scopes (Rails 5+)
Post.published.or(Post.featured)
# WHERE published = true OR featured = trueclass Post < ApplicationRecord
default_scope { where(published: true) }
end
# Hazard 1: applies to NEW records too
Post.new.published # => true (set by default_scope)
# Hazard 2: unscoped strips association scope too
user.posts.unscoped # => ALL posts, not just user's posts!
# Hazard 3: ORDER default_scope stacks
class Post < ApplicationRecord
default_scope { order(:created_at) }
end
Post.order(:title) # ORDER BY created_at, title (stacked!)
Post.reorder(:title) # ORDER BY title (replaces)
# Hazard 4: affects associations
user.posts # WHERE user_id = ? AND published = true
# hidden default_scope filter!# unscoped — removes ALL scopes (including association scope!)
Post.unscoped.where(id: 1)
# unscope — removes specific clauses
Post.published.recent.unscope(:order) # keeps WHERE, removes ORDER
Post.unscope(:where).order(:title) # removes WHERE, keeps ORDER
Post.unscope(where: :published) # removes specific WHERE condition
# Within association context, prefer unscope over unscoped
user.posts.unscope(:order) # safe — keeps the user_id constraint
user.posts.unscoped # unsafe — loses user_id constraint!Lifecycle hooks, ordering, and when they don't fire.
| Create | Update | Destroy |
|---|---|---|
|
before_validation after_validation before_save around_save before_create around_create after_create after_save after_commit / after_rollback |
before_validation after_validation before_save around_save before_update around_update after_update after_save after_commit / after_rollback |
before_destroy around_destroy after_destroy after_commit / after_rollback |
# after_save fires inside the transaction
# after_commit fires AFTER the transaction commits
# Use after_commit for side effects that shouldn't happen if rollback:
after_commit :send_welcome_email, on: :create
after_commit :notify_update, on: :update
after_commit :log_deletion, on: :destroy
# after_save is INSIDE the transaction:
# - If something later rolls back, after_save already fired
# - Enqueuing jobs in after_save = job may run before data committed
# → Use after_commit for job enqueuing!
# Shorthand:
after_create_commit :send_welcome_email
after_update_commit :notify_update
after_destroy_commit :log_deletion
after_save_commit :sync_to_cache # any save (create or update)after_commit, not after_save. A job that runs before the transaction commits will read stale/missing data.before_save :normalize_name, if: :name_changed?
before_save :set_slug, if: -> { title_changed? || slug.blank? }
after_create :send_email, unless: :system_user?
before_destroy :check_deps, if: :has_dependents?
# Multiple conditions (all must be true)
before_save :do_thing, if: [:published?, :title_changed?]# Skip a specific callback for a model (use sparingly in tests)
User.skip_callback(:create, :after, :send_welcome_email) do
User.create(name: "test")
end
# update_column / update_columns — bypass all callbacks + validations
user.update_column(:name, "New") # single column, no callbacks
user.update_columns(name: "New", role: "admin") # multiple
# update_attribute — bypasses validations but RUNS callbacks
user.update_attribute(:name, "New")
# save(validate: false) — skips validation, runs callbacks
# Method bypass matrix:
# update_attribute → skips validations, runs callbacks, touches updated_at
# update_column → skips both, skips updated_at
# update_columns → skips both, skips updated_at
# update_all → skips both, skips updated_at (class-level)# Throwing :abort halts the callback chain and returns false
before_create do
throw(:abort) if name.blank?
end
# Returns false from save / create:
user.save # => false if callback throws :abort
# false return value from callback no longer halts (Rails 5+)
# Must explicitly throw :abort
# Checking why save failed:
user.save # => false
user.errors.full_messages # may be empty if halted without errors
# Add errors before throwing abort for better UX:
before_create do
errors.add(:base, "Name required")
throw(:abort) if name.blank?
endThe non-obvious parts: contexts, options, and bypasses.
validates :name, presence: true
validates :email, presence: true,
uniqueness: { case_sensitive: false, scope: :account_id },
format: { with: URI::MailTo::EMAIL_REGEXP }
validates :age, numericality: { greater_than: 0, less_than: 150 }
validates :role, inclusion: { in: %w[admin mod user] }
validates :bio, length: { maximum: 500 }
validates :slug, uniqueness: true, if: :published?
# Multiple attrs same validation:
validates :first_name, :last_name, presence: truevalidates :password, presence: true, on: :create
validates :old_password, presence: true, on: :update
validates :terms, acceptance: true, on: :registration # custom context
# Trigger with context:
user.save(context: :registration)
user.valid?(:registration)
# Validate without saving:
user.valid? # runs all default validations
user.invalid? # inverse
user.errors # ActiveModel::Errors
user.errors[:email] # array of messages for :email
user.errors.full_messages# Inline with validate (method)
validate :slug_format
def slug_format
unless slug =~ /\A[a-z0-9-]+\z/
errors.add(:slug, "only lowercase letters, numbers, and hyphens")
end
end
# EachValidator class (reusable)
class EmailDomainValidator < ActiveModel::EachValidator
def validate_each(record, attribute, value)
unless value.end_with?("@myco.com")
record.errors.add(attribute, options[:message] || "must be a company email")
end
end
end
validates :email, email_domain: true
validates :email, email_domain: { message: "must end in @myco.com" }user.save(validate: false) # skips ALL validations, runs callbacks
user.update_attribute(:x, val) # skips validations, runs callbacks
user.update_column(:x, val) # skips validations AND callbacks
User.update_all(x: val) # skips both, class level
# uniqueness validation has a race condition!
# Always back it up with a DB-level unique index:
# add_index :users, :email, unique: true
# The validation is UX sugar; the index is the real guard.validates :email, uniqueness: true has a TOCTOU race condition. Always add a unique DB index as the actual constraint.Transaction semantics, savepoints, and the after_commit timing trap.
ActiveRecord::Base.transaction do
user.save!
account.update!(balance: account.balance - amount)
end
# Any exception rolls back the transaction
# Only ActiveRecord::Rollback is swallowed (not re-raised)
# All other exceptions propagate after rollback
# Force rollback without exception:
ActiveRecord::Base.transaction do
do_some_work
raise ActiveRecord::Rollback # rolls back, no exception propagates
end
# The model the transaction is called on doesn't matter —
# all AR connections in the same thread share the transaction:
User.transaction do
user.save!
order.save! # also in transaction, even though it's an Order
end# By default, nested transactions JOIN the outer transaction
# (no savepoint)
User.transaction do
User.transaction do # joins outer — NOT a savepoint
user.save!
end # inner commit is a no-op
end # outer commit fires here
# requires_new: true creates a savepoint
User.transaction do
User.transaction(requires_new: true) do
user.save!
raise ActiveRecord::Rollback # only rolls back to savepoint
end # outer continues
other.save!
end
# Gotcha: ActiveRecord::Rollback raised in inner transaction
# without requires_new: true will propagate as a no-op,
# but outer transaction will STILL commit!requires_new: true join the outer transaction. An ActiveRecord::Rollback raised in the inner block is silently ignored.# Pessimistic locking — SELECT ... FOR UPDATE
user = User.lock.find(1)
user = User.find(1)
user.lock! # issues SELECT ... FOR UPDATE
User.transaction do
user = User.lock.find(params[:id])
user.update!(balance: user.balance - 10)
end
# Optimistic locking — add lock_version integer column
# add_column :users, :lock_version, :integer, default: 0
User.find(1).save! # auto-increments lock_version
# Concurrent saves raise ActiveRecord::StaleObjectError
# Custom lock clauses:
User.lock("LOCK IN SHARE MODE").find(1) # MySQL
User.lock("FOR UPDATE SKIP LOCKED").where(...) # PG skip locked rows# after_save: fires inside the transaction
# after_commit: fires AFTER the transaction commits to DB
# Problem pattern:
after_save :enqueue_job # BAD — job may run before transaction commits
# Correct pattern:
after_commit :enqueue_job, on: :create # GOOD
# In tests: use_transactional_tests wraps each test in a transaction
# that never commits → after_commit never fires!
# Solutions:
# 1. DatabaseCleaner with :truncation strategy for those tests
# 2. Rails built-in: test.rb transactional callbacks workaround
# config.active_record.maintain_test_schema = true
# after_rollback — fires if transaction rolled back
after_rollback :cleanup_uploaded_filesafter_commit never fires in transactional tests. If you're testing jobs enqueued in after_commit, your test suite may silently skip them.Production-safe patterns, index options, and zero-downtime gotchas.
# OLD BAD (rewrites entire table — locks it):
add_column :users, :active, :boolean, default: true, null: false
# GOOD on PG 11+: constant defaults are instant (stored in catalog)
add_column :users, :active, :boolean, default: true, null: false
# PG 11+ handles constant defaults without table rewrite
# STILL SLOW on PG < 11 or for non-constant defaults:
# Step 1: add nullable column
add_column :users, :score, :integer
# Step 2: backfill in batches
User.find_in_batches { |g| User.where(id: g).update_all(score: 0) }
# Step 3: add NOT NULL + default after backfill
change_column_null :users, :score, false
change_column_default :users, :score, 0add_index :users, :email, unique: true
add_index :users, [:account_id, :email], unique: true # compound unique
add_index :users, :email, name: "idx_users_email_lower",
using: :btree # default; also :hash, :gin, :gist (PG)
# Partial index (PG) — much smaller, faster for filtered queries
add_index :users, :email, where: "deleted_at IS NULL"
# Concurrent index creation (PG) — doesn't lock table
add_index :users, :email, algorithm: :concurrently
# expression index
add_index :users, "lower(email)", name: "idx_users_lower_email"
# Remove
remove_index :users, :email
remove_index :users, column: :email
remove_index :users, name: "idx_users_email_lower"algorithm: :concurrently cannot run inside a transaction. Wrap the migration in disable_ddl_transaction!.class AddIndexToUsersEmail < ActiveRecord::Migration[7.1]
disable_ddl_transaction! # required for :concurrently
def change
add_index :users, :email,
algorithm: :concurrently,
if_not_exists: true # idempotent
end
end# change: auto-reversible (add_column, add_index, create_table, etc.)
def change
add_column :users, :bio, :text
end
# up/down: when change isn't reversible
def up
change_column :users, :score, :decimal, precision: 10, scale: 2
end
def down
change_column :users, :score, :integer
end
# Reversible block inside change:
def change
reversible do |dir|
dir.up { execute "CREATE EXTENSION IF NOT EXISTS citext" }
dir.down { execute "DROP EXTENSION IF EXISTS citext" }
end
add_column :users, :email, :citext
end# Without bulk: each change = separate ALTER TABLE (slow)
def change
change_table :users, bulk: true do |t| # single ALTER TABLE
t.add :bio, :text
t.add :score, :integer, default: 0
t.remove :legacy_col
t.rename :old_name, :new_name
end
end
# Note: bulk: true is MySQL-specific optimization
# PG handles multiple columns in one ALTER TABLE natively# Removing a column: AR caches column list at boot
# If you remove a column while app is running, existing processes
# will fail trying to SELECT the removed column.
# Safe sequence (zero-downtime):
# 1. Tell AR to ignore the column (before migration):
class User < ApplicationRecord
self.ignored_columns = [:old_col]
end
# 2. Deploy app code with ignored_columns
# 3. Run migration to drop the column
# 4. Remove ignored_columns from model
# Renaming: same issue — use ignored_columns + new column + backfill
# Avoid rename_column in zero-downtime deploymentsignored_columns, then the actual migration. Never drop a column in a single deploy.add_reference :posts, :user, foreign_key: true # adds user_id + FK
add_reference :posts, :user, foreign_key: true,
index: { algorithm: :concurrently } # concurrent index
add_foreign_key :posts, :users # adds FK constraint
add_foreign_key :posts, :users, on_delete: :cascade # DB-level cascade
# Check constraints (Rails 6.1+, PG)
add_check_constraint :users, "age > 0", name: "age_positive"
# Timestamps
add_timestamps :posts # adds created_at, updated_at
add_column :posts, :published_at, :datetime, null: true
# Default value
change_column_default :users, :role, from: nil, to: "user"