Rails 8 Query Interface Deep Dive

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
end

The 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
end

The 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
end

Subqueries 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
end

The 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 because customer_id isn't loaded.
  • Ordering with eager_load on has_many: JOINing a has_many creates duplicate parent records. Use distinct or switch to preload.
  • Forgetting about lazy evaluation: Relations don't execute until needed. @orders = Order.all in 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.

21 claps
← Back to Blog