ActiveRecord Cheat Sheet

Rails 7+ Advanced

Querying

All query methods return an ActiveRecord::Relation (lazy) until enumerated. Chaining is free.

Basic Finders

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

where / where.not

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
Never: .where("name = '#{params[:name]}'") — SQL injection

select / pluck / pick

# 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]

Aggregates

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).count

Ordering / Limiting

User.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"])

Existence & Predicates

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

Batch Iteration

# 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 control
find_each ignores .order() — it forces ORDER BY id internally

Raw SQL & Arel

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

Subqueries & CTEs

# 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")

Misc Useful Methods

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 query

Performance & Gotchas

The most important tab. These are the patterns that silently wreck production.

N+1 Queries

The Classic N+1

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

Detecting N+1

# 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 out

count vs size vs length

posts = 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!
posts.count always fires a query, even after you've loaded the relation. Use .size by default.

exists? vs any? vs present? vs empty?

MethodSQL firedWhen already loadedUse when
exists?SELECT 1 LIMIT 1still fires querychecking existence cheaply
any?SELECT 1 LIMIT 1uses in-memory if loadedgeneral purpose
none?SELECT 1 LIMIT 1uses in-memory if loadedgeneral purpose
empty?SELECT COUNT(*)uses in-memory if loadedavoid, prefer none?
present?loads all recordsuses in-memory if loadednever for relations
blank?loads all recordsuses in-memory if loadednever for relations
Never use present? or blank? on an AR relation — they call to_a first, loading all records.

pluck vs select vs map

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

update_all / delete_all / destroy_all

MethodSQLCallbacksValidationsAssociations
update_allUPDATE ... WHEREskippedskippednot touched
delete_allDELETE ... WHEREskippedskippednot cascaded
destroy_allloads + DELETE per rowrunsn/acascaded
# 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 deps
update_all does NOT auto-update updated_at. You must include it manually.

destroy vs delete

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 Gotchas

# 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) }
Avoid default_scope. It applies everywhere including associations, and unscoped strips ALL scopes including the association constraint.

Strict Loading

# 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 = true
Enable strict_loading_by_default in test env to catch N+1s at the source.

Selecting Only What You Need

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

Touch & Updated At Cascades

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 vs joins for Filtering

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

Query Debugging

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_count

Dirty Tracking Gotchas

user = 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 changes
After save, changes is empty. Use saved_changes in after_save callbacks.

Eager Loading

Four strategies, each with different SQL behavior. Picking the wrong one is the #1 source of query inefficiency.

Strategy Comparison

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

preload — always 2 queries

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 table

includes — smart (sometimes too smart)

Post.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])

eager_load — always LEFT OUTER JOIN

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.

joins — no association loading

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

When includes Picks JOIN vs Preload

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

Nested & Complex Eager Loading

# 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 query

Associations

Key options and non-obvious behavior. Assumes you know the basics.

has_many Options

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_exception

has_many :through

class 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 through

belongs_to Options

belongs_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 identity

Polymorphic

class 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 associations
Polymorphic associations can't be JOINed efficiently — you can't write a single JOIN that covers multiple commentable_type values.

has_one

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

Association Methods & Gotchas

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

Scopes

Named scopes, scope composition, and the default_scope trap.

Named Scopes

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)

Scope Chaining & Merging

# 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 = true

default_scope — Hazards

class 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!
Avoid default_scope. It causes subtle bugs and is hard to opt out of cleanly.

unscoped & unscope

# 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!

Callbacks

Lifecycle hooks, ordering, and when they don't fire.

Lifecycle Order

CreateUpdateDestroy
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_commit vs after_save

# 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)
Enqueue background jobs in after_commit, not after_save. A job that runs before the transaction commits will read stale/missing data.

Conditional Callbacks

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?]

Skipping Callbacks

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

Halting Callbacks

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

Validations

The non-obvious parts: contexts, options, and bypasses.

Common Validators

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

Validation Contexts

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

Custom Validators

# 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" }

Bypassing Validations

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.

Transactions

Transaction semantics, savepoints, and the after_commit timing trap.

Basics

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

Nested Transactions & Savepoints

# 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!
Nested transactions without requires_new: true join the outer transaction. An ActiveRecord::Rollback raised in the inner block is silently ignored.

Locking

# 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_commit Timing

# 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_files
after_commit never fires in transactional tests. If you're testing jobs enqueued in after_commit, your test suite may silently skip them.

Migrations

Production-safe patterns, index options, and zero-downtime gotchas.

add_column with Default — PostgreSQL Gotcha

# 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, 0

Index Options

add_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!.

Concurrent Index & 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 vs up/down

# 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

Bulk Changes (MySQL)

# 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

Renaming / Removing Columns Safely

# 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 deployments
Remove or rename columns in two deploys: first ignored_columns, then the actual migration. Never drop a column in a single deploy.

Useful Migration Helpers

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"