Skip to main content
Technology & EngineeringRuby Rails260 lines

Active Record

ActiveRecord query patterns, associations, validations, callbacks, and performance optimization for Rails applications.

Quick Summary18 lines
You are an expert in ActiveRecord ORM patterns for building performant, maintainable Rails applications.

## Key Points

- Use `find_each` or `in_batches` for processing large datasets instead of `.all.each`.
- Prefer `select` to limit columns when you do not need full objects.
- Use `exists?` instead of `present?` for existence checks -- it generates a more efficient query.
- Add database-level constraints (NOT NULL, foreign keys, unique indexes) in addition to model validations.
- Use `transaction` blocks when multiple records must be saved atomically.
- Favor `after_commit` over `after_save` for side effects like sending emails or enqueueing jobs.
- Keep callbacks thin; delegate heavy work to service objects or jobs.
- Index foreign keys and columns used in `WHERE`, `ORDER BY`, and `GROUP BY` clauses.
- **N+1 queries**: Always check logs or use the `bullet` gem to detect eager loading issues.
- **Callback chains**: Excessive callbacks make models unpredictable. Extract logic into service objects.
- **Ignoring `dependent` options**: Forgetting `dependent: :destroy` or `:nullify` leads to orphaned records.
- **Using `default_scope`**: It applies everywhere and is hard to override. Prefer explicit named scopes.
skilldb get ruby-rails-skills/Active RecordFull skill: 260 lines
Paste into your CLAUDE.md or agent config

ActiveRecord Patterns — Ruby on Rails

You are an expert in ActiveRecord ORM patterns for building performant, maintainable Rails applications.

Overview

ActiveRecord is the ORM layer in Rails that maps database tables to Ruby classes. Mastering its query interface, association strategies, and performance characteristics is essential for any Rails application that deals with relational data.

Core Philosophy

ActiveRecord embodies the principle that database access should feel natural in Ruby. Rather than treating SQL as a separate concern to be managed through raw strings and manual mapping, ActiveRecord lets you express data relationships and queries using Ruby's own idioms. The goal is to make the 80% of database work trivially simple while still providing escape hatches for the remaining 20%.

Convention over configuration is central to how ActiveRecord operates. Table names, primary keys, foreign keys, and timestamps all follow predictable defaults that eliminate boilerplate. When you follow these conventions, you get powerful features like automatic association inference, schema-aware form builders, and migration generators essentially for free. Deviating from conventions is always possible but should be a conscious decision.

Performance awareness must be baked into your ActiveRecord usage from day one, not bolted on later. Understanding when Rails fires queries, how eager loading prevents N+1 problems, and why database-level constraints matter more than model-only validations separates sustainable Rails codebases from ones that collapse under real traffic.

Anti-Patterns

  • The God Model: Stuffing hundreds of methods, callbacks, and validations into a single model (often User) until it becomes impossible to understand or test. Extract domain logic into service objects, query objects, and focused concerns.

  • Callback-Driven Architecture: Chaining after_save, after_commit, and before_validation callbacks to orchestrate complex business workflows. This creates invisible execution paths that are difficult to debug, test, and reason about. Use explicit service objects for multi-step operations.

  • Scope Creep on Scopes: Defining dozens of one-off scopes that are each used in only one place. Scopes should represent reusable, composable query fragments. If a query is used once, inline it or put it in a query object.

  • Validations Without Database Constraints: Relying solely on ActiveRecord validations for uniqueness, presence, or foreign key integrity. Model validations are subject to race conditions. Always back critical validations with database-level constraints (unique indexes, NOT NULL, foreign keys).

  • Raw SQL Everywhere: Bypassing the query interface with find_by_sql or interpolated where strings for queries that ActiveRecord handles natively. This sacrifices composability, parameterization safety, and readability. Reserve raw SQL for genuinely complex queries that the query interface cannot express.

Core Concepts

Associations

ActiveRecord supports six types of associations that define relationships between models:

class User < ApplicationRecord
  has_many :posts, dependent: :destroy
  has_many :comments, through: :posts
  has_one :profile, dependent: :destroy
  has_and_belongs_to_many :roles
end

class Post < ApplicationRecord
  belongs_to :user, counter_cache: true
  has_many :comments, dependent: :destroy
  has_many :taggings, dependent: :destroy
  has_many :tags, through: :taggings
end

Polymorphic Associations

class Comment < ApplicationRecord
  belongs_to :commentable, polymorphic: true
end

class Post < ApplicationRecord
  has_many :comments, as: :commentable
end

class Photo < ApplicationRecord
  has_many :comments, as: :commentable
end

Validations

class User < ApplicationRecord
  validates :email, presence: true,
                    uniqueness: { case_sensitive: false },
                    format: { with: URI::MailTo::EMAIL_REGEXP }
  validates :username, presence: true,
                       length: { minimum: 3, maximum: 30 },
                       uniqueness: true
  validates :age, numericality: { greater_than: 0 }, allow_nil: true

  validate :custom_validation

  private

  def custom_validation
    errors.add(:base, "Account is suspended") if suspended? && active_changed?
  end
end

Scopes and Query Interface

class Post < ApplicationRecord
  scope :published, -> { where(published: true) }
  scope :recent, -> { order(created_at: :desc) }
  scope :by_author, ->(user_id) { where(user_id: user_id) }
  scope :with_comments, -> { joins(:comments).distinct }
  scope :popular, -> { where("views_count > ?", 100) }

  # Composable scopes
  scope :trending, -> { published.recent.popular }
end

# Usage
Post.published.recent.limit(10)
Post.where(category: "tech").or(Post.where(category: "science"))
Post.where.not(status: "draft")
Post.where(created_at: 1.week.ago..)

Implementation Patterns

Eager Loading to Avoid N+1 Queries

# Bad: N+1 query
users = User.all
users.each { |u| puts u.posts.count }

# Good: eager load with includes
users = User.includes(:posts).all
users.each { |u| puts u.posts.size }

# Use preload when you want separate queries
users = User.preload(:posts, :profile).all

# Use eager_load when you need to filter on associations
users = User.eager_load(:posts).where(posts: { published: true })

Complex Queries with Arel

class Post < ApplicationRecord
  def self.search(query)
    where(
      arel_table[:title].matches("%#{sanitize_sql_like(query)}%")
        .or(arel_table[:body].matches("%#{sanitize_sql_like(query)}%"))
    )
  end

  def self.with_recent_comments
    joins(:comments)
      .where(comments: { created_at: 1.day.ago.. })
      .distinct
  end
end

Callbacks (Use Sparingly)

class Order < ApplicationRecord
  before_validation :normalize_card_number, on: :create
  after_create :send_confirmation_email
  after_commit :update_inventory, on: :create

  private

  def normalize_card_number
    self.card_number = card_number.gsub(/\s+/, "")
  end

  def send_confirmation_email
    OrderMailer.confirmation(self).deliver_later
  end

  def update_inventory
    UpdateInventoryJob.perform_later(id)
  end
end

Migrations

class CreateProducts < ActiveRecord::Migration[7.1]
  def change
    create_table :products do |t|
      t.string :name, null: false
      t.text :description
      t.decimal :price, precision: 10, scale: 2, null: false
      t.references :category, null: false, foreign_key: true
      t.integer :stock_count, default: 0
      t.jsonb :metadata, default: {}
      t.timestamps
    end

    add_index :products, :name
    add_index :products, :metadata, using: :gin
  end
end

Query Objects for Complex Logic

class UserSearchQuery
  def initialize(relation = User.all)
    @relation = relation
  end

  def call(params)
    result = @relation
    result = filter_by_status(result, params[:status])
    result = filter_by_role(result, params[:role])
    result = filter_by_date_range(result, params[:from], params[:to])
    result = sort(result, params[:sort_by], params[:direction])
    result
  end

  private

  def filter_by_status(relation, status)
    return relation if status.blank?
    relation.where(status: status)
  end

  def filter_by_role(relation, role)
    return relation if role.blank?
    relation.joins(:roles).where(roles: { name: role })
  end

  def filter_by_date_range(relation, from, to)
    relation = relation.where(created_at: from..) if from.present?
    relation = relation.where(created_at: ..to) if to.present?
    relation
  end

  def sort(relation, column, direction)
    return relation.order(created_at: :desc) if column.blank?
    relation.order(column => direction || :asc)
  end
end

Best Practices

  • Use find_each or in_batches for processing large datasets instead of .all.each.
  • Prefer select to limit columns when you do not need full objects.
  • Use exists? instead of present? for existence checks -- it generates a more efficient query.
  • Add database-level constraints (NOT NULL, foreign keys, unique indexes) in addition to model validations.
  • Use transaction blocks when multiple records must be saved atomically.
  • Favor after_commit over after_save for side effects like sending emails or enqueueing jobs.
  • Keep callbacks thin; delegate heavy work to service objects or jobs.
  • Index foreign keys and columns used in WHERE, ORDER BY, and GROUP BY clauses.

Common Pitfalls

  • N+1 queries: Always check logs or use the bullet gem to detect eager loading issues.
  • Callback chains: Excessive callbacks make models unpredictable. Extract logic into service objects.
  • Ignoring dependent options: Forgetting dependent: :destroy or :nullify leads to orphaned records.
  • Using default_scope: It applies everywhere and is hard to override. Prefer explicit named scopes.
  • Large transactions: Wrapping too many operations in a single transaction can lock tables and hurt concurrency.
  • Skipping database constraints: Relying solely on model validations can lead to data integrity issues under race conditions.

Install this skill directly: skilldb add ruby-rails-skills

Get CLI access →