LaunchKit
← All posts
· 6 min read · by The LaunchKit team · 1 views

find_each, in_batches and insert_all, priced in queries

The first time a rake task kills a server, it's almost always a loop over every row of a table. The fix is one method name, and there are three of them with genuinely different jobs. Here's what each costs, measured over 2,500 rows on Rails 8.1.4 by counting real queries off sql.active_record.

Reading: one query is the expensive one

Post.all.each { |p| p.title }               # 1 query
Post.find_each { |p| p.title }              # 3 queries
Post.find_each(batch_size: 500) { |p| p }   # 6 queries

The counts are the opposite way round from what you want, and that's the point. Post.all.each sends a single query and then holds 2,500 instantiated Active Record objects in memory at once. On 2,500 rows that's fine. On two million it's the thing that gets your process killed, and the query count was never the number to watch.

find_each trades queries for memory deliberately. Three queries for 2,500 rows at the default batch size of 1,000: two that return rows and one that comes back empty and ends the loop.

It walks a cursor, not an offset

This is the detail that makes find_each safe on a big table, and it's worth seeing.

-- first batch
SELECT "posts".* FROM "posts" ORDER BY "posts"."id" ASC LIMIT 1000
-- every batch after
SELECT "posts".* FROM "posts" WHERE "posts"."id" > ? ORDER BY "posts"."id" ASC LIMIT 1000

No OFFSET anywhere. We checked.

A paginated version would say LIMIT 1000 OFFSET 1000000, and the database would walk and discard a million rows to answer it, so batch one thousand costs a thousand times batch one. The id cursor costs the same every time, because the index does the seeking.

It also means rows arriving while you iterate get picked up if their ids sort after your cursor, and rows you've already passed don't. For a background job over a live table that's usually the behaviour you want, and it's worth knowing you have it.

The cost of the cursor is that find_each overrides your order. It has to sort by id, so an order(:created_at) you wrote upstream is silently discarded. If the order matters, find_each is the wrong tool.

Three ways to walk 2500 rows compared on two axes, query count and what is held in memory. The first, calling each on the whole relation, sends one query and holds all 2500 instantiated objects at once. The second, find_each at the default batch size of 1000, sends three queries and holds at most 1000 objects, and its SQL is shown as an id cursor, ordering by id ascending with a limit and then adding where id is greater than the last seen value, with no OFFSET anywhere. The third, in_batches, sends the same cursor queries but yields a relation rather than records, so a bulk update across a batch is a single additional query and nothing is instantiated at all.

in_batches gives you a relation, which changes what you can do

find_each yields records one at a time. in_batches yields the batch, and it yields it as a relation:

Post.in_batches(of: 1000) do |relation|
  relation.class                                   # => ActiveRecord::Relation
  relation.update_all(archived: true)              # 1 query for the whole batch
end

Nothing is instantiated. No callbacks run, no validations run, and 1,000 rows change in one statement. For "set a flag on every matching row" that's exactly right, and it's the reason to reach for in_batches over find_each when you're writing rather than reading.

The same shape works for delete_all and for pluck. If you need the records after all, relation.each gets them, and then you're back to find_each with extra steps.

Writing: insert_all against the loop

Post.insert_all(rows)            # 500 rows, 1 query
rows.each { |r| Post.create!(r) } # 500 rows, 1000 queries

A thousand to one. The two queries per create! are the uniqueness validation's SELECT and then the INSERT; drop the validation and it's one each, which is still 500.

What you give up is everything Active Record does for you. insert_all runs no validations, fires no callbacks, and doesn't touch created_at or updated_at unless those keys are in your hashes. We proved the first one by inserting a row with a nil title straight past a presence validation, and it landed in the table.

So the honest framing isn't "insert_all is faster". It's that insert_all is a different operation: it's SQL with a Ruby shape, and the record lifecycle isn't part of the deal. Use it for data you've already validated or data you trust, like an import you've checked or a seed.

It returns an ActiveRecord::Result rather than records, which surprises people reaching for the ids afterwards. insert_all(rows, returning: %w[id]) is the supported way to get them, and it's one round trip instead of a reload.

The rule this collapses into

Four cases and they don't overlap much.

Reading a lot of rows one at a time: find_each. Changing a lot of rows the same way: in_batches with update_all. Creating a lot of rows: insert_all. And each when you already know the table is small, which is most of the time and is why the habit survives long enough to hurt.

The one that gets missed is the second. Plenty of code uses find_each and then calls save! on each record, which is the slow path wearing the fast method's name.

What this page doesn't cover

No memory numbers. The whole argument for find_each is resident memory and there's no measurement of it here, because measuring Ruby's heap honestly takes more care than a query counter and the result depends on your object graph rather than on Rails.

Nothing here touches find_in_batches, which yields arrays rather than relations and sits between the two, nor the start: and finish: options that let you shard a batch job across processes. And none of it ran against a table large enough for the cursor to matter: 2,500 rows is enough to count queries and far too few to show you what OFFSET would have cost.

#rails #active-record #performance

Comments

No comments yet. Be the first.

Only used to confirm and publish your comment. Never shown publicly, never shared.

Markdown: **bold**, `code`, ```fenced blocks```, > quotes, [links](url). HTML and images are not rendered.