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.
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
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
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.
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.
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.
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 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)")
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.
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.
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.
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)"
)
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))
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.