ruby 64 lines · 3 tabs

Avoid Memory Blowups: find_each + select Columns

Shared by codesnips Jan 2026
3 tabs
class Order < ApplicationRecord
  belongs_to :customer

  scope :for_export, -> {
    select(:id, :reference, :total_cents, :currency, :created_at, :customer_id)
      .where.not(exported_at: nil)
  }

  def display_total
    format('%.2f %s', total_cents / 100.0, currency)
  end
end
3 files · ruby Explain with highlit

When a Rails app needs to export or process hundreds of thousands of rows, the naive Model.all.each loads every record into memory at once, which can exhaust the heap and take down a worker. The find_each API solves this by pulling records in batches (default 1000) and yielding them one at a time, so only a slice of the table lives in memory during iteration. Pairing that with a narrow select further shrinks each object, since ActiveRecord otherwise hydrates every column — including large text blobs — into every instance.

In Order model, the for_export scope encodes the two decisions that matter for memory: select limits the query to just the columns the export actually needs, and where.not(exported_at: nil) filters at the database rather than in Ruby. Selecting a subset of columns means the returned objects are partial; touching an unselected attribute like notes would raise ActiveModel::MissingAttributeError, which is a deliberate guardrail rather than a bug.

The heavy lifting happens in ExportOrdersJob, which streams straight to a file. It opens a CSV, writes the header once, then calls for_export.find_each with an explicit batch_size. Because find_each orders by primary key and pages using id > last_id under the hood, it avoids the OFFSET performance cliff that plagues LIMIT/OFFSET pagination on large tables. A larger batch_size means fewer round trips but more memory per batch, so it is tuned to the row width.

One subtlety: find_each ignores any explicit order clause because it must sort by primary key to page correctly, so ordering is applied later or not at all here. The serialize_row helper reads only the selected columns, keeping the object graph flat.

In ExportsController, the request thread does almost nothing — it enqueues the job and returns 202 Accepted, delegating the slow, memory-sensitive work to a background worker. This is the pattern to reach for whenever a full-table scan would otherwise be attempted inline: batch the reads, select only what is needed, and never build one giant array in memory.


Related snips

Share this code

Here's the card — post it anywhere.

Avoid Memory Blowups: find_each + select Columns — share card
Link copied