Skip to Content
Course content

181: Common Ruby Interview Questions on Database Optimization

Click on the "Edit" button in the top corner of the screen to edit your slide content.

I've sat through dozens of Ruby interviews, and there is one specific area where candidates usually stumble: database optimization. Most people know the terminology, but when I ask them to actually look at a log file and tell me why a page is slow, they freeze. So, let's stop talking about theory and actually break something together.

That familiar wall of SQL

Imagine we're building a small community forum. We have User models and Post models. I want to render a list of the ten most recent posts, and right next to each post, I want to show the username of the person who wrote it. Here is the "intuitive" way most people write this in a controller or a view:

@posts = Post.limit(10).order(created_at: :desc)

@posts.each do |post|
  puts "#{post.title} - Written by: #{post.user.username}"
end

On my machine, with five posts, this feels instant. But I'm looking at the Rails server logs, and I see something alarming. I see one query to get the posts, and then ten individual queries to get the user for each post. It looks like a waterfall of SELECT "users".* FROM "users" WHERE "users"."id" = ?. This is the classic N+1 problem. The "1" is the query for the posts; the "N" is the number of users we have to fetch.

In an interview, the answer they want is "eager loading." Let's try includes. I'll tweak the query:

@posts = Post.includes(:user).limit(10).order(created_at: :desc)

Now, when I check the logs, the waterfall is gone. ActiveRecord does two queries: one for the posts, and one massive query using WHERE "users"."id" IN (...) to grab all necessary users at once. We just traded ten round-trips to the database for one. That's a massive win for latency.

Tuning the data stream

Now, let's say our User model is huge. Maybe we're storing bios, profile settings, and encrypted tokens in that table. When I use includes(:user), ActiveRecord does a SELECT *. I'm pulling back kilobytes of data for every user just to display a 15-character username. It's wasteful.

I often see candidates suggest select here, but select doesn't play nicely with includes in the way you'd expect. If I just want a list of names for a dropdown or a simple report, I'll try pluck instead. Let's see what happens if I just need the usernames of everyone who has posted:

# This loads full User objects into memory
usernames = User.joins(:posts).distinct.map(&:username)

# This does it all in the database
usernames = User.joins(:posts).distinct.pluck(:username)

The first version is a memory hog. It instantiates a Ruby object for every single user. The second version—pluck—skips the ActiveRecord object creation entirely and returns a simple array of strings. If you're in an interview and you mention that pluck avoids the overhead of object instantiation, you're already ahead of 90% of the room.

When the dataset bites back

Finally, let's talk about scale. Let's say I need to run a cleanup script that sends an email to every single user who has an inactive account. I might be tempted to do this:

User.where(active: false).each do |user|
  UserMailer.inactive_notification(user).deliver_now
end

I tried this on a staging database with 100,000 users, and the process crashed with an NoMemoryError. Why? Because .each on an ActiveRecord relation attempts to load every single record into a Ruby array before it starts iterating. My RAM simply couldn't hold 100,000 User objects.

I need to process these in chunks. I'll swap .each for .find_each:

User.where(active: false).find_each(batch_size: 1000) do |user|
  UserMailer.inactive_notification(user).deliver_now
end

Now, if I watch my memory monitor, it stays flat. ActiveRecord fetches 1,000 records, processes them, throws them away, and fetches the next 1,000. It's the difference between trying to swallow a whole pizza in one bite and actually taking slices. When an interviewer asks about "batch processing" or "handling large datasets," find_each is your best friend.




📋 Practical Task

Optimizing the Analytics Dashboard Export

You have been handed a legacy report generator that is timing out in production. The current code fetches all Order records, finds the associated Customer for each to get their email, and calculates a total. It's currently written as follows:

# Current slow implementation
def generate_report
  orders = Order.all 
  orders.each do |order|
    puts "#{order.customer.email}: #{order.total_amount}"
  end
end

Your Task: Rewrite the generate_report method to implement the following optimizations:

  • Solve the N+1 query problem so that customers are loaded efficiently.
  • Ensure the application doesn't crash on memory if the orders table grows to 500,000 rows.
  • Modify the logic so that it only pulls the email and id from the customers table, rather than the entire customer record (use pluck or select where appropriate).

Provide your optimized Ruby code and a brief explanation of why your changes prevent the NoMemoryError and the N+1 issue.

Rating
0 0

There are no comments for now.

to be the first to leave a comment.