Active Record's query interface often gets reduced to basic where clauses and simple associations. Yet Rails 8 offers a rich set of query methods that can eliminate N+1 queries, reduce memory usage, and make code more expressive. This guide covers the query patterns that separate novice Rails code from production-ready applications.
The Problem with Basic Queries
Consider a typical scenario: displaying a list of orders with customer names and order totals. A naive implementation might look clean but hide serious performance issues.
# app/controllers/orders_controller.rb
class OrdersController < ApplicationController
def index
# This triggers N+1 queries
@orders = Order.all
end
end
# app/views/orders/index.html.erb
<% @orders.each do |order| %>
<%= order.customer.name %> - <%= order.line_items.sum(&:total) %>
<% end %>This code generates one query for orders, then one query per order for the customer, and another per order for line items. With 100 orders, that's 201 database queries. Rails 8 provides several tools to solve this elegantly.
Eager Loading with includes, preload, and eager_load
Rails offers three methods for eager loading, each with distinct behavior. Understanding when to use each prevents both N+1 queries and unnecessary memory consumption.
# app/controllers/orders_controller.rb
class OrdersController < ApplicationController
def index
# includes: lets Rails choose the strategy
# Uses preload (separate queries) by default
# Switches to eager_load (JOIN) if you filter on the association
@orders = Order.includes(:customer, :line_items)
.where(status: :completed)
.order(created_at: :desc)
.limit(50)
end
def by_customer_country
# eager_load: forces LEFT OUTER JOIN
# Required when filtering or ordering by associated table columns
@orders = Order.eager_load(:customer)
.where(customers: { country: "US" })
.order("customers.name ASC")
end
def with_products
# preload: forces separate queries
# Better for has_many with large datasets (avoids cartesian product)
@orders = Order.preload(:line_items)
.where("total_cents > ?", 10000)
end
endThe includes method works well for most cases. Use eager_load when filtering on associated tables. Choose preload when loading many has_many records to avoid result set multiplication from JOINs.
Selecting Only What's Needed
Loading entire Active Record objects when only a few columns are needed wastes memory and slows queries. Rails 8 offers select, pluck, and pick for efficient data retrieval.
# app/models/order.rb
class Order < ApplicationRecord
belongs_to :customer
has_many :line_items
scope :summary, -> {
select(:id, :order_number, :status, :total_cents, :created_at)
}
scope :with_customer_name, -> {
joins(:customer)
.select("orders.*, customers.name as customer_name")
}
end
# app/services/order_report_service.rb
class OrderReportService
def self.generate_for_period(start_date, end_date)
# pluck returns arrays, not Active Record objects
# Much faster for large datasets
order_data = Order.where(created_at: start_date..end_date)
.pluck(:order_number, :total_cents, :created_at)
# pick returns a single value or array for one record
latest_order_number = Order.order(created_at: :desc).pick(:order_number)
# ids is a shortcut for pluck(:id)
completed_ids = Order.where(status: :completed).ids
{
orders: order_data,
latest: latest_order_number,
completed_count: completed_ids.size
}
end
endThe pluck method bypasses Active Record instantiation entirely, returning raw arrays. This makes it ideal for exports, reports, and any scenario where model methods aren't needed.
Advanced Querying with Arel and Subqueries
Complex business logic sometimes requires queries beyond what the standard interface provides. Rails 8 integrates smoothly with Arel for these cases while keeping code readable.
# app/models/customer.rb
class Customer < ApplicationRecord
has_many :orders
# Subquery to find customers with recent high-value orders
scope :high_value_active, -> {
recent_big_orders = Order.where("created_at > ?", 30.days.ago)
.where("total_cents > ?", 50000)
.select(:customer_id)
where(id: recent_big_orders)
}
# Using Arel for complex conditions
scope :search_by_name_or_email, ->(query) {
return none if query.blank?
sanitized = "%#{sanitize_sql_like(query)}%"
where(
arel_table[:name].matches(sanitized)
.or(arel_table[:email].matches(sanitized))
)
}
# Combining with MySQL-specific features
scope :ordered_by_total_spent, -> {
joins(:orders)
.group(:id)
.select("customers.*, COALESCE(SUM(orders.total_cents), 0) as total_spent")
.order("total_spent DESC")
}
end
# app/controllers/customers_controller.rb
class CustomersController < ApplicationController
def vip_list
@customers = Customer.high_value_active
.ordered_by_total_spent
.includes(:orders)
.limit(100)
end
endSubqueries execute as single SQL statements, avoiding the round-trip overhead of fetching IDs in Ruby and passing them back. This pattern works especially well for membership checks and filtering based on associated record conditions.
Batch Processing for Large Datasets
Processing thousands of records requires careful memory management. Rails 8 provides find_each, find_in_batches, and in_batches for different batch processing needs.
# app/services/order_archival_service.rb
class OrderArchivalService
BATCH_SIZE = 1000
def self.archive_old_orders(before_date)
archived_count = 0
# find_each yields individual records
# Good for operations that need the full model
Order.where("created_at < ?", before_date)
.where(status: :completed)
.find_each(batch_size: BATCH_SIZE) do |order|
order.archive!
archived_count += 1
end
archived_count
end
def self.bulk_update_status(order_ids, new_status)
# in_batches yields ActiveRecord::Relation objects
# Enables efficient bulk operations
Order.where(id: order_ids).in_batches(of: BATCH_SIZE) do |batch|
batch.update_all(status: new_status, updated_at: Time.current)
end
end
def self.export_to_csv(start_date, end_date)
# find_in_batches yields arrays of records
# Useful when you need to process groups together
CSV.generate do |csv|
csv << ["Order Number", "Total", "Date"]
Order.where(created_at: start_date..end_date)
.find_in_batches(batch_size: BATCH_SIZE) do |orders|
orders.each do |order|
csv << [order.order_number, order.total_cents, order.created_at]
end
end
end
end
endThe key difference: find_each and find_in_batches load records into memory, while in_batches yields relation objects suitable for update_all or delete_all operations that bypass model callbacks.
Common Mistakes to Avoid
Several query patterns appear correct but cause subtle issues in production:
- Chaining after pluck:
Order.pluck(:id).where(...)fails because pluck returns an array, not a relation. Place pluck last in the chain. - Using includes with select: Eager loading requires foreign keys.
Order.includes(:customer).select(:id)breaks becausecustomer_idisn't loaded. - Ordering with eager_load on has_many: JOINing a has_many creates duplicate parent records. Use
distinctor switch to preload. - Forgetting about lazy evaluation: Relations don't execute until needed.
@orders = Order.allin a controller doesn't hit the database until the view iterates.
Summary
Effective Active Record usage requires understanding the underlying SQL. Use includes for most eager loading, pluck for data extraction, subqueries for complex conditions, and batch methods for large datasets. The query methods covered here form the foundation for Rails applications that scale gracefully from prototype to production traffic.