| name | active-record-querying |
| description | Expert guidance for writing efficient, correct Active Record queries in Rails 8.1. Use when writing queries, finding records, building scopes, fixing N+1 queries, using where clauses, includes, joins, eager loading, filtering, searching, plucking data, selecting records, or optimizing database queries. Covers where, find, scopes, includes vs preload vs eager_load, joins, pluck, select, calculations, batching, and query anti-patterns. |
| allowed-tools | Read, Grep, Glob, Write, Edit, Bash(bin/rails console), Bash(bin/rails runner) |
Active Record Querying Expert
Write efficient, correct, and maintainable Active Record queries. Avoid N+1s, unnecessary memory allocation, and query anti-patterns.
Philosophy
- Let the database do the work — Filter, sort, count, and aggregate in SQL, not Ruby
- Load only what you need — Don't
SELECT * when you need one column
- Prevent N+1 by default — Always think about associations before iterating
- Scopes over ad-hoc queries — Named, composable, testable
- Fail loudly — Use bang methods when a missing record is a bug
Decision Trees
Finding a Single Record
Need a record by primary key?
→ find(id) # Raises if not found — this is usually what you want
→ find_by(id: id) # Returns nil if not found
Need a record by attributes?
→ find_by(email: "x@y.com") # Returns nil — good for "maybe exists" cases
→ find_by!(email: "x@y.com") # Raises — good for "must exist" cases
Avoid:
→ where(email: "x").first # Unnecessary — find_by does this in one step
→ where(email: "x").take # Same thing, less clear intent
Loading Associations (N+1 Prevention)
Do you need associated data while iterating?
YES → Use eager loading (see below)
NO → Don't eager load — it wastes memory
Which eager loading method?
includes → DEFAULT CHOICE. Rails picks best strategy (2 queries or JOIN)
preload → FORCE separate queries. Use when includes picks wrong strategy
eager_load → FORCE single LEFT OUTER JOIN. Use when you need to filter on association
The includes/preload/eager_load decision:
| Method | Strategy | When to use |
|---|
includes | Auto (usually 2 queries) | Default. Handles most cases correctly |
preload | Always separate queries | When includes incorrectly uses a JOIN, or you want predictable query count |
eager_load | Always LEFT OUTER JOIN | When you need where conditions on the association |
joins | INNER JOIN (no loading) | When you need to filter but DON'T need the associated objects |
posts = Post.includes(:comments).where(published: true)
posts = Post.eager_load(:comments).where(comments: { approved: true })
posts = Post.joins(:comments).where(comments: { approved: true }).distinct
posts = Post.all.select { |p| p.comments.any? }
posts = Post.where.associated(:comments)
Checking Existence
User.exists?(email: "x@y.com")
User.where(active: true).exists?
User.where(active: true).any?
User.where(active: true).present?
User.where(active: true).to_a.any?
User.where(active: true).count > 0
Rule: exists? > any? > present? > count > 0
Getting Data Out
Need Ruby objects with methods/callbacks?
→ Use where/find/select (returns AR objects)
Need raw values for display, export, or IDs?
→ Use pluck (returns arrays, skips AR instantiation)
Need a single value?
→ Use pick (like pluck but returns one value)
Need to count/sum/average?
→ Use count/sum/average/minimum/maximum (SQL aggregates)
user_ids = User.where(active: true).pluck(:id)
emails = User.where(role: :admin).pluck(:email)
pairs = User.pluck(:id, :email)
User.where(id: 1).pick(:email)
User.where(active: true).map(&:id)
User.where(active: true).select(:id).map(&:id)
User.where(active: true).ids
Order.where(status: :complete).sum(:total)
Order.average(:total)
Product.maximum(:price)
Scopes
When to Use Scopes vs Class Methods
Use scopes for:
- Simple, composable query fragments
- Conditions you chain frequently
- Queries that should ALWAYS return a relation (never nil)
Use class methods for:
- Complex queries with conditional logic
- Queries that might return nil (class method nil = no scope applied; scope nil =
.all)
- Queries that need multiple statements
class Post < ApplicationRecord
scope :published, -> { where(published: true) }
scope :recent, -> { order(created_at: :desc) }
scope :by_author, ->(author) { where(author: author) }
scope :popular, -> { where("view_count > ?", 100) }
def self.search(query)
return all if query.blank?
where("title ILIKE ?", "%#{sanitize_sql_like(query)}%")
end
scope :created_before, ->(time) { where(created_at: ...time) if time.present? }
scope :created_before, ->(time) { time.present? ? where(created_at: ...time) : all }
end
Post.published.recent.by_author(user).limit(10)
Scope Anti-Patterns
class Post < ApplicationRecord
default_scope { where(deleted: false) }
end
scope :active, -> { where(active: true).where(verified: true).where("last_login > ?", 30.days.ago) }
scope :active, -> { where(active: true) }
scope :verified, -> { where(verified: true) }
scope :recently_active, -> { where("last_login > ?", 30.days.ago) }
Batching Large Datasets
Iterate large tables in batches — .each on an unbounded relation loads the entire result set into memory at once:
User.all.each { |u| u.send_newsletter }
User.find_each { |u| u.send_newsletter }
User.find_each(batch_size: 500) { |u| u.send_newsletter }
User.find_in_batches(batch_size: 1000) do |batch|
SomeService.bulk_process(batch)
end
User.in_batches(of: 1000) do |batch_relation|
batch_relation.update_all(processed: true)
end
Batching methods:
find_each — yields individual records. Most common.
find_in_batches — yields arrays of records. For bulk operations on objects.
in_batches — yields Relations. For bulk SQL operations (update_all, delete_all).
- All three sort by primary key internally. They can't be combined with custom
.order() because batching relies on PK ordering to paginate.
Joins
joins vs includes — Know the Difference
Post.joins(:comments).where(comments: { approved: true }).distinct
Post.includes(:comments).where(published: true)
posts = Post.joins(:author).limit(10)
posts.each { |p| p.author.name }
posts = Post.includes(:author).limit(10)
posts.each { |p| p.author.name }
left_outer_joins
Customer.left_outer_joins(:orders)
.select("customers.*, COUNT(orders.id) AS orders_count")
.group("customers.id")
Customer.where.associated(:orders)
Customer.where.missing(:orders)
Where Conditions
Hash Conditions (Preferred)
User.where(active: true)
User.where(role: [:admin, :moderator])
Order.where(created_at: 1.week.ago..Time.current)
Order.where(total: 100..)
Order.where(total: ..100)
User.where(deleted_at: nil)
User.where.not(role: :banned)
User.where.not(deleted_at: nil)
User.where(role: :admin).or(User.where(role: :moderator))
Post.where(author: { active: true })
String Conditions (When Hash Won't Work)
User.where("email LIKE ?", "%#{User.sanitize_sql_like(query)}%")
User.where("created_at > :date", date: 1.week.ago)
User.where("email = '#{params[:email]}'")
Raw SQL
When It's OK
- Complex queries that AR can't express cleanly
- Performance-critical queries where you need specific SQL
- Reporting/analytics queries with complex aggregations
How to Do It Safely
User.where("age > ? AND city = ?", 18, "NYC")
User.where("age > :min_age AND city = :city", min_age: 18, city: "NYC")
User.where("name LIKE ?", "#{User.sanitize_sql_like(query)}%")
results = User.find_by_sql([
"SELECT users.*, COUNT(orders.id) as order_count
FROM users LEFT JOIN orders ON orders.user_id = users.id
WHERE users.active = ?
GROUP BY users.id
HAVING COUNT(orders.id) > ?",
true, 5
])
result = ActiveRecord::Base.lease_connection.select_all(
"SELECT DATE(created_at) as day, COUNT(*) as total FROM orders GROUP BY day"
)
result.to_a
users = User.arel_table
User.where(users[:age].gt(18).and(users[:city].eq("NYC")))
Enums
class Order < ApplicationRecord
enum :status, { pending: 0, processing: 1, shipped: 2, delivered: 3, cancelled: 4 }
end
Order.pending
Order.not_pending
Order.shipped
order.pending?
order.shipped!
Order.where(status: :shipped)
Order.where(status: [:shipped, :delivered])
Order.where(status: 2)
Common Anti-Patterns
1. Loading Everything Into Memory
User.all.select { |u| u.active? }
User.all.count
User.where(active: true)
User.count
2. N+1 Queries
Post.limit(10).each { |p| puts p.author.name }
Post.includes(:author).limit(10).each { |p| puts p.author.name }
3. Unnecessary Eager Loading
Post.includes(:comments, :tags, :author).find(1)
Post.find(1)
Post.includes(:author).find(1)
4. Using map Where pluck Works
User.where(active: true).map(&:email)
User.select(:email).map(&:email)
User.where(active: true).pluck(:email)
5. count vs size vs length
relation = User.where(active: true)
relation.count
relation.length
relation.size
6. Forgetting distinct with joins
Post.joins(:comments).where(comments: { approved: true })
Post.joins(:comments).where(comments: { approved: true }).distinct
Query Debugging
puts User.where(active: true).to_sql
puts User.where(active: true).explain
ActiveRecord::Base.logger = Logger.new(STDOUT)
assert_queries_count(2) { User.includes(:posts).first.posts.to_a }
assert_no_queries { cached_result }
Quick Reference: Method Cheat Sheet
| Want to... | Use | Returns |
|---|
| Find by PK (must exist) | find(id) | Record or raises |
| Find by PK (might not exist) | find_by(id: id) | Record or nil |
| Find by attributes | find_by(email: "x") | Record or nil |
| Find by attributes (must exist) | find_by!(email: "x") | Record or raises |
| Filter records | where(active: true) | Relation |
| Exclude records | where.not(role: :banned) | Relation |
| Has association | where.associated(:orders) | Relation |
| Missing association | where.missing(:orders) | Relation |
| Sort | order(created_at: :desc) | Relation |
| Limit | limit(10) | Relation |
| Offset | offset(20) | Relation |
| Distinct | distinct | Relation |
| Raw values | pluck(:email) | Array |
| Single raw value | pick(:email) | Value |
| All IDs | ids | Array |
| Count | count | Integer |
| Exists? | exists?(email: "x") | Boolean |
| Group | group(:status).count | Hash |
| Sum/Avg/Min/Max | sum(:total) | Numeric |
| Eager load (auto) | includes(:author) | Relation |
| Eager load (separate queries) | preload(:author) | Relation |
| Eager load (single JOIN) | eager_load(:author) | Relation |
| INNER JOIN (filter only) | joins(:author) | Relation |
| LEFT JOIN | left_outer_joins(:orders) | Relation |
| Batch iterate | find_each | yields records |
| Batch arrays | find_in_batches | yields arrays |
| Batch relations | in_batches | yields Relations |
| Find or create | find_or_create_by(name: "x") | Record |
| Find or init | find_or_initialize_by(name: "x") | Record (maybe unsaved) |
For detailed patterns, advanced examples, and edge cases, see the references/ directory:
references/finders.md — Finder methods, where conditions, ordering, select/pluck, enums
references/joins-and-includes.md — Joins, eager loading (includes/preload/eager_load), strict_loading
references/scopes.md — Scope patterns, overriding conditions, method chaining, query objects
references/batching.md — find_each, find_in_batches, in_batches
references/calculations.md — Grouping, aggregations, existence checks, locking
references/raw-sql.md — Safe raw SQL patterns (find_by_sql, select_all, sanitization)
references/performance.md — Performance patterns, Rails 8.1 features, full method index