Ruby on Rails
ActiveRecord Cookbook

A practical, growing reference of ActiveRecord recipes for production Rails apps — with generated SQL, performance notes, and the mistakes that keep showing up in code review.

Tested against modern Rails (7+). Updated as the codebase catches new patterns.

This is a living ActiveRecord cookbook. Instead of a full API reference, it collects the exact recipes that come up while shipping and maintaining Rails applications — the ones that usually send developers to Stack Overflow or the Rails guides in the middle of a task.

Every example is written the way it would look in a production codebase. Where the generated SQL clarifies behavior, it is included. Where a query has a common misuse or a faster alternative, that is called out as well.

Phase 1 covers finder methods and association queries. More sections — filtering, ordering, aggregations, updates, deletes, performance, scopes, PostgreSQL, JSONB, and advanced queries — will be added over time.

1. Finder Methods

The everyday query methods that show up in almost every Rails codebase — what they do, what SQL they generate, and where they trip people up.

# find

Problem

Load a record by primary key, and fail loudly when it is missing.

ActiveRecord

User.find(42)
User.find(1, 2, 3) # returns an array

SQL

SELECT "users".* FROM "users" WHERE "users"."id" = 42 LIMIT 1;
SELECT "users".* FROM "users" WHERE "users"."id" IN (1, 2, 3);

Why This Works

find is optimized for primary-key lookups and raises ActiveRecord::RecordNotFound when a record is missing, which Rails converts to a 404 in controllers.

Performance Notes

  • Uses the primary key index — always fast.
  • The array form issues a single IN query, not one query per id.
  • Order of records in the array form is not guaranteed to match the input.

Common Mistakes

  • Rescuing RecordNotFound to fall back to find_by. Use find_by directly when a nil result is acceptable.
  • Assuming User.find([1, 2, 3]) preserves order — sort explicitly if you need it.

Alternative

User.find_by(id: 42) # returns nil instead of raising

# find_by

Problem

Fetch the first record matching a condition, or nil if none exists.

ActiveRecord

User.find_by(email: "jane@example.com")
User.find_by(email: "jane@example.com", active: true)

SQL

SELECT "users".* FROM "users"
WHERE "users"."email" = 'jane@example.com'
LIMIT 1;

Why This Works

find_by is a shortcut for where(...).first — one query, single record, no exception.

Performance Notes

  • Ensure the lookup column is indexed. On columns like email, add a unique index.
  • For case-insensitive lookups, prefer a functional index (lower(email)) or a citext column over where("LOWER(email) = ?", ...).

Common Mistakes

  • Chaining .first after find_byfind_by already returns a single record.
  • Using find_by inside a loop instead of a single where(id: ids) query — that is an N+1.

# find_by!

Problem

Fetch a record by attributes and raise if it does not exist.

ActiveRecord

User.find_by!(email: params[:email])

Why This Works

Identical to find_by, but raises ActiveRecord::RecordNotFound when nothing is found — which Rails renders as a 404 in controllers, matching the behavior of find.

Common Mistakes

  • Using find_by! where a nil result is a valid business outcome — the raised exception becomes a noisy 404 in error trackers.

Alternative

user = User.find_by(email: params[:email])
return head :not_found unless user

# first

Problem

Return the first record from a relation, ordered by primary key by default.

ActiveRecord

User.first
User.where(active: true).first
User.order(:created_at).first(3) # returns an array of 3

SQL

SELECT "users".* FROM "users" ORDER BY "users"."id" ASC LIMIT 1;

Why This Works

When no order is set, first adds ORDER BY primary_key ASC to make the result deterministic.

Performance Notes

  • The implicit ORDER BY id uses the primary key index and stays fast.
  • If you already applied an order, that ordering is respected instead.

Common Mistakes

  • Assuming first respects insertion order without an explicit order. It orders by primary key, which usually — but not always — matches insertion order.

# last

Problem

Return the last record of a relation.

ActiveRecord

User.last
User.order(:created_at).last

SQL

SELECT "users".* FROM "users" ORDER BY "users"."id" DESC LIMIT 1;

Why This Works

Without an explicit order, ActiveRecord reverses ORDER BY id ASC to DESC and takes one row. If you have an order clause, it inverts that instead.

Performance Notes

  • Cheap on the default ordering thanks to the primary key index.
  • Loading last against a large ordered scope may load everything in memory if the relation has already been loaded — call last before .to_a, not after.

Common Mistakes

  • Using last to mean "most recently created". If IDs aren't strictly monotonic (UUIDs, sharded ids), that assumption breaks. Use order(created_at: :desc).first when the intent is temporal.

# take

Problem

Return a single record without imposing an order.

ActiveRecord

User.take
User.where(active: true).take
User.take(5) # array of up to 5

SQL

SELECT "users".* FROM "users" LIMIT 1;

Why This Works

take issues a plain LIMIT 1 — no ORDER BY. Use it when you only need any matching row.

Performance Notes

  • Slightly faster than first when order does not matter, because the database can return the first row it finds.

Common Mistakes

  • Using take where consistent ordering matters (pagination, "first" record semantics). Results can vary between runs.

# sole

Problem

Fetch exactly one record and raise if zero or more than one matches.

ActiveRecord

User.where(email: "jane@example.com").sole

Why This Works

Available on Rails 7+. Raises ActiveRecord::RecordNotFound when nothing matches and ActiveRecord::SoleRecordExceeded when more than one row matches. Useful for enforcing uniqueness invariants at query time.

Performance Notes

  • Issues a LIMIT 2 internally to detect the "more than one" case without pulling every row.
  • Back the lookup column with a unique index so duplicates are impossible in the first place.

Common Mistakes

  • Using sole on data that is not guaranteed unique — the exception will fire in production the moment a duplicate slips in.

# exists?

Problem

Check whether any record matches a condition without loading it.

ActiveRecord

User.exists?(42)
User.exists?(email: "jane@example.com")
User.where(active: true).exists?

SQL

SELECT 1 AS one FROM "users"
WHERE "users"."email" = 'jane@example.com'
LIMIT 1;

Why This Works

The database returns a single constant, not the row, so no columns are read and no ActiveRecord object is instantiated.

Performance Notes

  • Much cheaper than .any? or .count > 0 when the relation is not already loaded.
  • .any? on an unloaded relation issues a COUNT(*)exists? is the right primitive.

Common Mistakes

  • Using User.where(...).count.positive?. That performs a full COUNT(*) when a LIMIT 1 would do.

Alternative

users.load if needed_later
users.any? # OK after the relation is already loaded

# ids

Problem

Get an array of primary keys for a relation.

ActiveRecord

User.where(active: true).ids
# => [1, 4, 7, 9, ...]

SQL

SELECT "users"."id" FROM "users" WHERE "users"."active" = TRUE;

Why This Works

Equivalent to pluck(primary_key). Returns a plain Ruby array of IDs, skipping model instantiation entirely.

Performance Notes

  • Only reads the primary key column — the smallest possible payload.
  • Good for feeding into where(id: ids), delete_all, or background jobs.

Common Mistakes

  • Pulling millions of ids into memory. Use find_each(:pluck) { ... } or batched deletes instead.

# pick

Problem

Pull one or more column values from a single row.

ActiveRecord

User.where(email: "jane@example.com").pick(:id)
User.where(active: true).pick(:id, :name)

SQL

SELECT "users"."id", "users"."name" FROM "users"
WHERE "users"."active" = TRUE
LIMIT 1;

Why This Works

pick is limit(1).pluck(...).first in one call — one row, only the columns you asked for.

Performance Notes

  • Cheaper than find_by(...).id: no model instantiation, only the requested columns are read.

Common Mistakes

  • Using pluck(...).first instead of pick(...). Without limit(1), the database returns every matching row before Ruby throws them away.

# pluck

Problem

Load an array of raw column values without instantiating models.

ActiveRecord

User.where(active: true).pluck(:email)
User.pluck(:id, :email)
# => [[1, "jane@example.com"], [2, "john@example.com"]]

SQL

SELECT "users"."email" FROM "users" WHERE "users"."active" = TRUE;

Why This Works

pluck bypasses model instantiation, casting only the columns you asked for. It is the fastest way to get raw values.

Performance Notes

  • Reads only the selected columns — huge win on wide tables.
  • Loads the entire result set into memory. For large sets, combine with in_batches.
  • Type casting still runs, so decimals and times return as the expected Ruby type.

Common Mistakes

  • User.all.map(&:email) — instantiates every user just to read one column.
  • Plucking after an includes — the eager-loaded associations are thrown away.

# select

Problem

Return ActiveRecord objects containing only a subset of columns, or computed columns.

ActiveRecord

User.select(:id, :email)
User.select("users.*, COUNT(orders.id) AS orders_count")
    .left_joins(:orders)
    .group("users.id")

SQL

SELECT "users"."id", "users"."email" FROM "users";

Why This Works

Unlike pluck, select returns real model instances. Attributes that were not selected are missing — accessing them raises ActiveModel::MissingAttributeError.

Performance Notes

  • Useful for wide tables where you only need a couple of columns but still want model behavior (methods, scopes).
  • Combine with computed aggregates to expose values that don't exist as columns.

Common Mistakes

  • Selecting a subset and later calling an accessor for a missing column — production surprise waiting to happen.
  • Using select when you actually want pluck. If you don't need model instances, pluck is faster.

# find_each

Problem

Iterate over a large set of records without loading them all into memory.

ActiveRecord

User.where(active: true).find_each(batch_size: 500) do |user|
  UserSyncJob.perform_later(user.id)
end

SQL

SELECT "users".* FROM "users"
WHERE "users"."active" = TRUE
ORDER BY "users"."id" ASC
LIMIT 500;
-- next batch:
SELECT "users".* FROM "users"
WHERE "users"."active" = TRUE
  AND "users"."id" > 500
ORDER BY "users"."id" ASC
LIMIT 500;

Why This Works

find_each paginates through records by primary key using keyset pagination, yielding one at a time. Memory stays flat regardless of table size.

Performance Notes

  • Always keyset-paginated by primary key — that's why the ordering is enforced.
  • Default batch size is 1,000. Tune it against memory versus round-trip count.
  • Custom order clauses are ignored (and warn) — this is intentional to keep pagination consistent.

Common Mistakes

  • Using .each on a giant table — loads everything into memory.
  • Expecting a custom order to be respected. Use in_batches with an explicit key if you need different ordering.

# find_in_batches

Problem

Process large tables in chunks — for example, sending a batch to an external API.

ActiveRecord

User.where(active: true).find_in_batches(batch_size: 1_000) do |batch|
  BillingApi.upsert_batch(batch.map { |u| u.slice(:id, :email) })
end

Why This Works

Same keyset pagination as find_each, but yields the batch itself. Ideal for bulk APIs, bulk inserts, or writing to a file per chunk.

Performance Notes

  • Prefer in_batches (returns a relation per batch) when you want to chain update_all, delete_all, or pluck without instantiating models.
  • Batch size is a memory-versus-throughput trade — larger batches mean more RAM per iteration but fewer round trips.

Common Mistakes

  • Using find_in_batches when you only need update_all. in_batches.update_all(...) skips model instantiation entirely.

Alternative

User.where(active: false).in_batches(of: 5_000).update_all(archived_at: Time.current)

2. Association Queries

Recipes that come up whenever you have a parent/child relationship. The examples below assume this pair of models.

class Parent < ApplicationRecord
  has_many :children
end

class Child < ApplicationRecord
  belongs_to :parent
end

# Parents without children

Problem

Find every parent that has zero associated children.

ActiveRecord

Parent.where.missing(:children)

SQL

SELECT "parents".*
FROM "parents"
LEFT OUTER JOIN "children" ON "children"."parent_id" = "parents"."id"
WHERE "children"."id" IS NULL;

Why This Works

where.missing(:children) is the Rails 6.1+ shorthand for a LEFT OUTER JOIN with an IS NULL filter on the joined side.

Performance Notes

  • Requires an index on children.parent_id — this is the default with t.references :parent.
  • On very large child tables, NOT EXISTS can outperform the LEFT JOIN. Benchmark both against real data.

Common Mistakes

  • Using Parent.where.not(id: Child.select(:parent_id)) — this is fine but can misbehave if parent_id is nullable and contains NULL rows (SQL NOT IN semantics).

Alternative

Parent.where("NOT EXISTS (SELECT 1 FROM children WHERE children.parent_id = parents.id)")

# Parents with children

Problem

Return only parents that have at least one child.

ActiveRecord

Parent.where.associated(:children)

SQL

SELECT "parents".*
FROM "parents"
INNER JOIN "children" ON "children"."parent_id" = "parents"."id";

Why This Works

where.associated(:children) (Rails 7+) is the counterpart to where.missing — inner join, no extra condition.

Performance Notes

  • The inner join can duplicate parent rows when a parent has multiple children. Use distinct or select("DISTINCT parents.*") if you're rendering a list.

Common Mistakes

  • Forgetting distinct and rendering the same parent multiple times.
  • Using Parent.joins(:children) without realizing it silently drops parents with no children — sometimes intentional, often not.

# Parents with exactly one child

Problem

Return parents that have exactly one associated child.

ActiveRecord

Parent.joins(:children)
      .group("parents.id")
      .having("COUNT(children.id) = 1")

SQL

SELECT "parents".*
FROM "parents"
INNER JOIN "children" ON "children"."parent_id" = "parents"."id"
GROUP BY "parents"."id"
HAVING COUNT("children"."id") = 1;

Why This Works

GROUP BY collapses per parent, HAVING filters aggregates — the correct place to compare counts, not WHERE.

Performance Notes

  • Composite index on (children.parent_id) keeps the join and group fast.
  • Consider a counter cache (children_count) if this query runs on every page load.

# Parents with more than one child

Problem

Return parents whose child count is greater than one.

ActiveRecord

Parent.joins(:children)
      .group("parents.id")
      .having("COUNT(children.id) > 1")

Common Mistakes

  • Filtering the count in WHERE — SQL rejects aggregates in WHERE, they belong in HAVING.

# Parents with more than N children

Problem

Parameterize the threshold, e.g. "parents with more than 5 children".

ActiveRecord

n = 5
Parent.joins(:children)
      .group("parents.id")
      .having("COUNT(children.id) > ?", n)

Common Mistakes

  • String-interpolating n directly — always parameterize, even in HAVING.

# Parents with fewer than N children

Problem

Include parents with zero children in the "fewer than N" count.

ActiveRecord

n = 3
Parent.left_joins(:children)
      .group("parents.id")
      .having("COUNT(children.id) < ?", n)

Why This Works

left_joins keeps parents with no children in the result. With joins (inner), zero-child parents would be excluded.

Common Mistakes

  • Using joins instead of left_joins. Silently drops the zero-child parents, which are often the ones you care about.

# Parents ordered by child count

Problem

Order parents by how many children they have, most first.

ActiveRecord

Parent.left_joins(:children)
      .group("parents.id")
      .order(Arel.sql("COUNT(children.id) DESC"))

SQL

SELECT "parents".*
FROM "parents"
LEFT OUTER JOIN "children" ON "children"."parent_id" = "parents"."id"
GROUP BY "parents"."id"
ORDER BY COUNT("children"."id") DESC;

Performance Notes

  • A children_count counter cache makes this a plain ORDER BY parents.children_count DESC — one index scan, no join, no group.
  • Arel.sql is required to bypass Rails' unsafe-order protection when passing an expression.

# Parents ordered by most recent child

Problem

Show parents whose most recent child was created latest, first.

ActiveRecord

Parent.left_joins(:children)
      .group("parents.id")
      .order(Arel.sql("MAX(children.created_at) DESC NULLS LAST"))

Why This Works

MAX(children.created_at) per parent gives the timestamp of the newest child. NULLS LAST sends parents with no children to the bottom.

Performance Notes

  • Index children (parent_id, created_at) lets the planner grab the max per group efficiently.
  • NULLS LAST is PostgreSQL / Oracle syntax; MySQL uses ORDER BY ... IS NULL, ....

# Parents having children created today

Problem

Return parents that received a new child today.

ActiveRecord

Parent.joins(:children)
      .where(children: { created_at: Time.current.all_day })
      .distinct

SQL

SELECT DISTINCT "parents".*
FROM "parents"
INNER JOIN "children" ON "children"."parent_id" = "parents"."id"
WHERE "children"."created_at" BETWEEN '...' AND '...';

Performance Notes

  • Time.current.all_day produces a range in the app time zone — safer than string interpolation.
  • Index children (created_at) or (parent_id, created_at) for common filters.

Common Mistakes

  • Comparing against Date.today — mixes Date and Timestamp semantics and can miss rows near midnight in different zones.

# Parents whose children match a condition

Problem

Find parents that have at least one child with a given status.

ActiveRecord

Parent.joins(:children).where(children: { status: "flagged" }).distinct

Why This Works

The inner join restricts to matching child rows, and distinct collapses duplicated parents.

Performance Notes

  • An EXISTS subquery is often faster than JOIN ... DISTINCT on large child tables.

Alternative

Parent.where(id: Child.where(status: "flagged").select(:parent_id))

# Orphan children (no parent)

Problem

Find children whose parent no longer exists — usually a data-integrity check.

ActiveRecord

Child.where.missing(:parent)

SQL

SELECT "children".*
FROM "children"
LEFT OUTER JOIN "parents" ON "parents"."id" = "children"."parent_id"
WHERE "parents"."id" IS NULL;

Why This Works

belongs_to is required by default in Rails 5+, so an orphan means either a missing foreign key or a deletion that skipped dependent:.

Performance Notes

  • Add a real foreign key constraint (add_foreign_key) so orphans can't exist in the first place.

# Find duplicate associations

Problem

Detect duplicates — for example, children that share a (parent_id, name) pair.

ActiveRecord

Child.group(:parent_id, :name)
     .having("COUNT(*) > 1")
     .pluck(:parent_id, :name, Arel.sql("COUNT(*)"))

SQL

SELECT "children"."parent_id", "children"."name", COUNT(*)
FROM "children"
GROUP BY "children"."parent_id", "children"."name"
HAVING COUNT(*) > 1;

Performance Notes

  • Add a partial/unique index on (parent_id, name) to prevent new duplicates once the existing ones are cleaned up.

# Count children per parent

Problem

Return a hash of parent_id => children_count.

ActiveRecord

Child.group(:parent_id).count
# => { 1 => 4, 2 => 1, 5 => 12 }

SQL

SELECT "children"."parent_id", COUNT(*)
FROM "children"
GROUP BY "children"."parent_id";

Performance Notes

  • Parents with zero children are missing from the hash — merge with Parent.pluck(:id) if you need them.
  • For hot paths, a counter cache turns this into a single indexed column read.

Common Mistakes

  • Parent.all.map { |p| [p.id, p.children.count] }.to_h — classic N+1.

# Parents with no active children

Problem

Return parents that have zero children matching active: true. Parents with no children at all should also be included.

ActiveRecord

Parent.where.not(id: Child.where(active: true).select(:parent_id))

SQL

SELECT "parents".*
FROM "parents"
WHERE "parents"."id" NOT IN (
  SELECT "children"."parent_id" FROM "children" WHERE "children"."active" = TRUE
);

Why This Works

The subquery returns every parent that does have an active child. NOT IN gives the complement.

Common Mistakes

  • If children.parent_id is nullable and the subquery could return NULL, NOT IN yields unknown for every row. Use NOT EXISTS when nullability is uncertain.

Alternative

Parent.where(
  "NOT EXISTS (SELECT 1 FROM children WHERE children.parent_id = parents.id AND children.active = TRUE)"
)

# Parents with at least one active child

Problem

Return parents that have one or more active children.

ActiveRecord

Parent.where(id: Child.where(active: true).select(:parent_id))

Performance Notes

  • An IN (subquery) is typically as fast as an EXISTS subquery on modern PostgreSQL.
  • Index children (parent_id, active) when this condition runs often.

Alternative

Parent.joins(:children).where(children: { active: true }).distinct

# Parents where every child matches a condition

Problem

Find parents where every child is active — and the parent has at least one child.

ActiveRecord

Parent.joins(:children)
      .group("parents.id")
      .having("COUNT(*) = COUNT(CASE WHEN children.active THEN 1 END)")

Why This Works

Total child count equals the number of active children only when there are no inactive children. The inner join guarantees at least one row per parent.

Performance Notes

  • Use COUNT(*) FILTER (WHERE children.active) on PostgreSQL for a cleaner and slightly faster expression.

Alternative

# Parents with at least one child AND no inactive children
Parent.where.associated(:children)
      .where.not(id: Child.where(active: false).select(:parent_id))

# Distinct parents through joins

Problem

Return each matching parent once, even when the join multiplies rows.

ActiveRecord

Parent.joins(:children).where(children: { active: true }).distinct

SQL

SELECT DISTINCT "parents".*
FROM "parents"
INNER JOIN "children" ON "children"."parent_id" = "parents"."id"
WHERE "children"."active" = TRUE;

Performance Notes

  • DISTINCT parents.* forces a sort/hash. On big result sets, an EXISTS subquery avoids that cost entirely.

Common Mistakes

  • Combining distinct with an order on a non-selected column — PostgreSQL rejects it. Add the column to the SELECT list or restructure the query.

# left_joins vs joins

Problem

Pick between joins (INNER) and left_joins (LEFT OUTER) — the wrong pick silently drops rows or bloats them.

ActiveRecord

# INNER JOIN: only parents that have at least one matching child
Parent.joins(:children).where(children: { active: true })

# LEFT OUTER JOIN: all parents, matched or not
Parent.left_joins(:children).where("children.id IS NULL OR children.active = TRUE")

# Aggregating including zero-child parents
Parent.left_joins(:children)
      .group("parents.id")
      .select("parents.*, COUNT(children.id) AS children_count")

Why This Works

Inner joins filter parents to those with matching children. Left joins keep every parent and expose child columns as NULL when there is no match. When you aggregate or count children, left_joins is almost always the right default.

Performance Notes

  • joins tends to be cheaper than left_joins because the planner can drop rows earlier.
  • Both need indexes on the foreign key column (children.parent_id).

Common Mistakes

  • Using joins when the intent is "all parents, optionally with children" — the parents without children silently vanish.
  • Using left_joins everywhere without a filter — you can end up scanning the full child table for nothing.

Conclusion

This is Phase 1 of the SparkRails ActiveRecord cookbook — finders and association queries, the two areas that show up in almost every code review. Upcoming sections will cover filtering, ordering, aggregations, updates and deletes, scopes, performance patterns, and PostgreSQL-specific queries including JSONB.

If a recipe you rely on is missing, or a query in your codebase deserves a better version, that is exactly the kind of thing that will feed the next update.

Need help with real ActiveRecord problems?

SparkRails helps SaaS teams debug complex ActiveRecord queries, optimize Rails performance, and maintain production Ruby on Rails applications.

Talk to SparkRails
*****

Made With Love ❤️