0% found this document useful (0 votes)
65 views

Nplusone Queries Basics

This document discusses n+1 query problems in Rails and provides examples to help understand and address them. An n+1 query problem occurs when a query is executed for each result of an initial query, leading to n+1 total queries. The document reviews techniques like preload, eager_load, includes, and joins that can help prevent n+1 queries by loading associated data in additional queries or joins. It then provides exercises using sample models (Accommodations, Rooms, Reviews) for the reader to practice identifying and solving n+1 query problems using these techniques.
Copyright
© © All Rights Reserved
Available Formats
Download as PDF, TXT or read online on Scribd
0% found this document useful (0 votes)
65 views

Nplusone Queries Basics

This document discusses n+1 query problems in Rails and provides examples to help understand and address them. An n+1 query problem occurs when a query is executed for each result of an initial query, leading to n+1 total queries. The document reviews techniques like preload, eager_load, includes, and joins that can help prevent n+1 queries by loading associated data in additional queries or joins. It then provides exercises using sample models (Accommodations, Rooms, Reviews) for the reader to practice identifying and solving n+1 query problems using these techniques.
Copyright
© © All Rights Reserved
Available Formats
Download as PDF, TXT or read online on Scribd
You are on page 1/ 38

N+1 queries basics

by Benito Serna

1 / 38
Index
What is an n+1 queries problem
Understanding preload eager_load includes and joins
Preloading practice
Understanding when the query will be executed
Practice the identification of when the query will be executed
Tools to help you detect n+1 queries
Final notes

2 / 38
What is an n+1 queries problem?
An n+1 queries problem means that a query is executed for every result of a previous query.
For example, with this records...

class Post < ActiveRecord::Base


has_many :comments
end

class Comment < ActiveRecord::Base


belongs_to :post
end

If you execute the next code...

Post.all.each do |post|
post.comments.each do |comment|
puts comment.body
end
end

You will execute...


1 query to fetch the posts (Post.all)
And n queries to fetch the comments of each post (post.comments)
If there are...
2 posts, it will execute 3 queries (2 + 1)
5 posts, it will execute 6 queries (5 + 1)
1000 posts, it will execute 1001 queries (1000 + 1)

3 / 38
Understanding preload, eager_load, includes and joins
Rails provide a lot of methods to work with associations, but the ones that are more involved in fixin n+1 queries
are preload, eager_load, includes and joins.
Here you will find a simple explanation with examples to help you understand the difference between them and
to help you decide which is better on different use cases.
Example data
Imagine that you have the next data in your database

class Post < ActiveRecord::Base


has_many :comments
end

class Comment < ActiveRecord::Base


belongs_to :post
end

post = Post.create(title: "Post 1", body: "Post 1 body")


post.comments.create(body: "Comment 1")
post.comments.create(body: "Comment 2")

post = Post.create(title: "Post 2", body: "Post 2 body")


post.comments.create(body: "Comment 1")
post.comments.create(body: "Comment 2")

Preload
It preloads the associociations using different queries.
For example, this code will use one query for the posts and other for the comments.

posts = Post.preload(:comments).to_a
# SELECT "posts".* FROM "posts"
# SELECT "comments".* FROM "comments" WHERE "comments"."post_id" IN ($1, $2)
[[nil, 1], [nil, 2]]

And you will be able to use the comments without n+1 queries.

4 / 38
posts.map(&:comments)

You can include conditions for the base model.

posts = Post.preload(:comments).where(title: "Post 1").to_a


# SELECT "posts".* FROM "posts" WHERE "posts"."title" = $1 [["title", "Post
1"]]
# SELECT "comments".* FROM "comments" WHERE "comments"."post_id" IN ($1, $2)
[[nil, 1], [nil, 2]]

But you can't add conditions for the preloaded associations.

Post.preload(:comments).where(comments: {body: "Hola"}).to_a


# `prepare': PG::UndefinedTable: ERROR: missing FROM-clause entry for table
"comments" (ActiveRecord::StatementInvalid)
# LINE 1: SELECT "posts".* FROM "posts" WHERE "comments"."body" = $1

Eager load
It forces the eager loading in one query performing a LEFT OUTER JOIN.
For example, this code will use just one query to load both, the posts and the comments.

posts = Post.eager_load(:comments).to_a
# SELECT "posts"."id" AS t0_r0, "posts"."title" AS t0_r1, "posts"."body" AS
t0_r2, "comments"."id" AS t1_r0, "comments"."body" AS t1_r1,
"comments"."post_id" AS t1_r2 FROM "posts" LEFT OUTER JOIN "comments" ON
"comments"."post_id" = "posts"."id"

And you will be able to use the comments without n+1 queries.

posts.map(&:comments)

You will be able to add conditions for the base model.

5 / 38
Post.eager_load(:comments).where(title: "Hola").to_a
# SELECT "posts"."id" AS t0_r0, "posts"."title" AS t0_r1, "posts"."body" AS
t0_r2, "comments"."id" AS t1_r0, "comments"."body" AS t1_r1,
"comments"."post_id" AS t1_r2 FROM "posts" LEFT OUTER JOIN "comments" ON
"comments"."post_id" = "posts"."id" WHERE "posts"."title" = $1 [["title",
"Hola"]]

And you also will be able to add conditions for the associated models.

Post.eager_load(:comments).where(comments: {body: "Hola"}).to_a


# SELECT "posts"."id" AS t0_r0, "posts"."title" AS t0_r1, "posts"."body" AS
t0_r2, "comments"."id" AS t1_r0, "comments"."body" AS t1_r1,
"comments"."post_id" AS t1_r2 FROM "posts" LEFT OUTER JOIN "comments" ON
"comments"."post_id" = "posts"."id" WHERE "comments"."body" = $1 [["body",
"Hola"]]

Includes
By default it will load the included associations using different queries, like preload.
For example, this code will use one query for the posts and other for the comments.

posts = Post.includes(:comments).to_a
# SELECT "posts".* FROM "posts"
# SELECT "comments".* FROM "comments" WHERE "comments"."post_id" IN ($1, $2)
[[nil, 1], [nil, 2]]

And you will be able to use the comments without new queries.

posts.map(&:comments)

You will also be able to add conditions for the base model.

Post.includes(:comments).where(title: "Hola").to_a
# SELECT "posts".* FROM "posts" WHERE "posts"."title" = $1 [["title", "Post
1"]]
# SELECT "comments".* FROM "comments" WHERE "comments"."post_id" IN ($1, $2)
[[nil, 1], [nil, 2]]

6 / 38
And in this case, you will also be able to add conditions for the included associations. Because it will perform a
LEFT OUTER JOIN like eager load does.

Post.includes(:comments).where(comments: {body: "Hola"}).to_a


# SELECT "posts"."id" AS t0_r0, "posts"."title" AS t0_r1, "posts"."body" AS
t0_r2, "comments"."id" AS t1_r0, "comments"."body" AS t1_r1,
"comments"."post_id" AS t1_r2 FROM "posts" LEFT OUTER JOIN "comments" ON
"comments"."post_id" = "posts"."id" WHERE "comments"."body" = $1 [["body",
"Hola"]]

Note that if you want to pass the coditions as strings, you will need to use the references method, like this...

Post.includes(:comments).where("comments.body = ?",
"Hola").references(:comments).to_a

So, includes is like a mix between preload and eager_load.


Joins
By default it will perform an INNER JOIN that you could use to create a more specific query using the joined
tables.
For example you can ask for the comments for a post with an specific title, like this...

comments = Comment.joins(:post).where(post: {title: "Post 1"}).to_a


# SELECT "comments".* FROM "comments" INNER JOIN "posts" post ON post."id" =
"comments"."post_id" WHERE "post"."title" = $1 [["title", "Post 1"]]

But it will not preload the post for each comment.

comments.map(&:post)
# SELECT "posts".* FROM "posts" WHERE "posts"."id" = $1 LIMIT $2 [["id", 2],
["LIMIT", 1]]
# SELECT "posts".* FROM "posts" WHERE "posts"."id" = $1 LIMIT $2 [["id", 2],
["LIMIT", 1]]

If you want to preload the post, you would need to use one of the methods above (includes, preload, eager
load)...
For example...
7 / 38
comments = Comment.joins(:post).where(post: {title: "Post
1"}).preload(:post).to_a
# SELECT "comments".* FROM "comments" INNER JOIN "posts" post ON post."id" =
"comments"."post_id" WHERE "post"."title" = $1 [["title", "Post 1"]]
# SELECT "posts".* FROM "posts" WHERE "posts"."id" = $1 [["id", 2]]

And now you will avoid the n+1 queries when you do...

comments.map(&:post)

You must be aware that when you joins a has many association rails will return duplicated records, because it
will return an instance for each row in the joined table.

Post.count #=> 2
Post.joins(:comments).count #=> 4

To fix this issue you can use distinct...

Post.distinct.count #=> 2
Post.joins(:comments).distinct.count #=> 4

Useful links
You can use the rails docs to learn more:
preload
eager_load
includes
joins

8 / 38
Preloading practice
Now that you know the theory here you will find a set of examples to help you practice this 4 methods, and
some more.
For each exercise try read the description the task and write as solution before watching the answer, but if you
find the problem too hard, don't worry, on the answers section you will find:
The description of a task
The query written with ActiveRecord
The produced SQL query
The model of the example
All the examples are based on the next model…

ActiveRecord::Schema.define(version: 1) do
create_table :accomodations, if_not_exists: true do |t|
t.column :name, :string
t.column :bathrooms_count, :decimal
t.column :guests_count, :integer
end

create_table :rooms, if_not_exists: true do |t|


t.column :accomodation_id, :integer
t.column :name, :string
t.column :beds_count, :integer
end

create_table :reviews, if_not_exists: true do |t|


t.column :accomodation_id, :integer
t.column :body, :text
t.column :stars, :integer
end
end

9 / 38
class Accomodation < ActiveRecord::Base
has_many :rooms
has_many :reviews
end

class Room < ActiveRecord::Base


end

class Review < ActiveRecord::Base


end

Working environment
To help you really work on this examples I have prepared a git repo with the setup that you need to run the
examples.
On /exercises you will find a list of exercises with:
The description of a task
And a function to write the results
You should provide a way to fetch the records.
For example:

puts "-----------------------------"
puts "TASK"
puts "1. Fetch all acomodations preloading rooms (with includes)"
puts "-----------------------------"

# accomodations = ?
# display_with_rooms(accomodations)

You can find the repo here: github.com/bhserna/preloading_quiz


There you will find the instructions to run the project.

10 / 38
Exercises
. Fetch all acomodations preloading rooms (with includes)
. Fetch all acomodations preloading rooms (with preload)
. Fetch all acomodations preloading rooms (with eager load)
. Fetch all acomodations preloading reviews (with includes)
. Fetch all acomodations preloading reviews (with preload)
. Fetch all acomodations preloading reviews (with eager_load)
. Fetch all acomodations preloading reviews and rooms (with includes)
. Fetch all acomodations preloading reviews and rooms (with preload)
. Fetch all acomodations preloading reviews and rooms (with eager_load)
. Fetch all acomodations for exactly 4 guests, preloading reviews (with includes)
. Fetch all acomodations for exactly 4 guests, preloading reviews (with preload)
. Fetch all acomodations for exactly 4 guests, preloading reviews (with eager_load)
. Fetch all acomodations with reviews of 4 stars (not in average), preloading reviews (with includes)
. Fetch all acomodations with reviews of 4 stars (not in average), preloading reviews (with preload)
. Fetch all acomodations with reviews of 4 stars (not in average), preloading reviews (with eager_load)
. List of accomodations with reviews of 4 stars (in average), preloading reviews (with preload)
. List of accomodations with exactly 2 rooms, preloading rooms (with preload)

11 / 38
Answers
. Fetch all acomodations preloading rooms (with includes)

Accomodation.includes(:rooms)

SELECT "accomodations".* FROM "accomodations"


SELECT "rooms".* FROM "rooms" WHERE "rooms"."accomodation_id" IN ($1, $2, $3,
$4) [[nil, 1], [nil, 2], [nil, 3], [nil, 4]]

. Fetch all acomodations preloading rooms (with preload)

Accomodation.preload(:rooms)

SELECT "accomodations".* FROM "accomodations"


SELECT "rooms".* FROM "rooms" WHERE "rooms"."accomodation_id" IN ($1, $2, $3,
$4) [[nil, 1], [nil, 2], [nil, 3], [nil, 4]]

. Fetch all acomodations preloading rooms (with eager load)

Accomodation.eager_load(:rooms)

SELECT "accomodations"."id" AS t0_r0, "accomodations"."name" AS t0_r1,


"accomodations"."bathrooms_count" AS t0_r2, "accomodations"."guests_count" AS
t0_r3, "rooms"."id" AS t1_r0, "rooms"."accomodation_id" AS t1_r1,
"rooms"."name" AS t1_r2, "rooms"."beds_count" AS t1_r3 FROM "accomodations"
LEFT OUTER JOIN "rooms" ON "rooms"."accomodation_id" = "accomodations"."id"

. Fetch all acomodations preloading reviews (with includes)

Accomodation.includes(:reviews)

12 / 38
SELECT "accomodations".* FROM "accomodations"
SELECT "reviews".* FROM "reviews" WHERE "reviews"."accomodation_id" IN ($1,
$2, $3, $4) [[nil, 1], [nil, 2], [nil, 3], [nil, 4]]

. Fetch all acomodations preloading reviews (with preload)

Accomodation.preload(:reviews)

SELECT "accomodations".* FROM "accomodations"


SELECT "reviews".* FROM "reviews" WHERE "reviews"."accomodation_id" IN ($1,
$2, $3, $4) [[nil, 1], [nil, 2], [nil, 3], [nil, 4]]

. Fetch all acomodations preloading reviews (with eager_load)

Accomodation.eager_load(:reviews)

SELECT "accomodations"."id" AS t0_r0, "accomodations"."name" AS t0_r1,


"accomodations"."bathrooms_count" AS t0_r2, "accomodations"."guests_count" AS
t0_r3, "reviews"."id" AS t1_r0, "reviews"."accomodation_id" AS t1_r1,
"reviews"."body" AS t1_r2, "reviews"."stars" AS t1_r3 FROM "accomodations"
LEFT OUTER JOIN "reviews" ON "reviews"."accomodation_id" =
"accomodations"."id"

. Fetch all acomodations preloading reviews and rooms (with includes)

Accomodation.includes(:reviews, :rooms)

SELECT "accomodations".* FROM "accomodations"


SELECT "reviews".* FROM "reviews" WHERE "reviews"."accomodation_id" IN ($1,
$2, $3, $4) [[nil, 1], [nil, 2], [nil, 3], [nil, 4]]
SELECT "rooms".* FROM "rooms" WHERE "rooms"."accomodation_id" IN ($1, $2, $3,
$4) [[nil, 1], [nil, 2], [nil, 3], [nil, 4]]

13 / 38
. Fetch all acomodations preloading reviews and rooms (with preload)

Accomodation.preload(:reviews, :rooms)

SELECT "accomodations".* FROM "accomodations"


SELECT "reviews".* FROM "reviews" WHERE "reviews"."accomodation_id" IN ($1,
$2, $3, $4) [[nil, 1], [nil, 2], [nil, 3], [nil, 4]]
SELECT "rooms".* FROM "rooms" WHERE "rooms"."accomodation_id" IN ($1, $2, $3,
$4) [[nil, 1], [nil, 2], [nil, 3], [nil, 4]]

. Fetch all acomodations preloading reviews and rooms (with eager_load)

Accomodation.eager_load(:reviews, :rooms)

SELECT "accomodations"."id" AS t0_r0, "accomodations"."name" AS t0_r1,


"accomodations"."bathrooms_count" AS t0_r2, "accomodations"."guests_count" AS
t0_r3, "reviews"."id" AS t1_r0, "reviews"."accomodation_id" AS t1_r1,
"reviews"."body" AS t1_r2, "reviews"."stars" AS t1_r3, "rooms"."id" AS t2_r0,
"rooms"."accomodation_id" AS t2_r1, "rooms"."name" AS t2_r2,
"rooms"."beds_count" AS t2_r3 FROM "accomodations" LEFT OUTER JOIN "reviews"
ON "reviews"."accomodation_id" = "accomodations"."id" LEFT OUTER JOIN "rooms"
ON "rooms"."accomodation_id" = "accomodations"."id"

. Fetch all acomodations for exactly 4 guests, preloading reviews (with includes)

Accomodation.where(guests_count: 4).includes(:reviews)

SELECT "accomodations".* FROM "accomodations" WHERE


"accomodations"."guests_count" = $1 [["guests_count", 4]]
SELECT "reviews".* FROM "reviews" WHERE "reviews"."accomodation_id" = $1
[["accomodation_id", 2]]

14 / 38
. Fetch all acomodations for exactly 4 guests, preloading reviews (with preload)

Accomodation.where(guests_count: 4).preload(:reviews)

SELECT "accomodations".* FROM "accomodations" WHERE


"accomodations"."guests_count" = $1 [["guests_count", 4]]
SELECT "reviews".* FROM "reviews" WHERE "reviews"."accomodation_id" = $1
[["accomodation_id", 2]]

. Fetch all acomodations for exactly 4 guests, preloading reviews (with eager_load)

Accomodation.where(guests_count: 4).eager_load(:reviews)

SELECT "accomodations"."id" AS t0_r0, "accomodations"."name" AS t0_r1,


"accomodations"."bathrooms_count" AS t0_r2, "accomodations"."guests_count" AS
t0_r3, "reviews"."id" AS t1_r0, "reviews"."accomodation_id" AS t1_r1,
"reviews"."body" AS t1_r2, "reviews"."stars" AS t1_r3 FROM "accomodations"
LEFT OUTER JOIN "reviews" ON "reviews"."accomodation_id" =
"accomodations"."id" WHERE "accomodations"."guests_count" = $1
[["guests_count", 4]]

. Fetch all acomodations with reviews of 4 stars (not in average), preloading reviews (with includes)

Accomodation.includes(:reviews).where(reviews: {stars: 4})

SELECT "accomodations"."id" AS t0_r0, "accomodations"."name" AS t0_r1,


"accomodations"."bathrooms_count" AS t0_r2, "accomodations"."guests_count" AS
t0_r3, "reviews"."id" AS t1_r0, "reviews"."accomodation_id" AS t1_r1,
"reviews"."body" AS t1_r2, "reviews"."stars" AS t1_r3 FROM "accomodations"
LEFT OUTER JOIN "reviews" ON "reviews"."accomodation_id" =
"accomodations"."id" WHERE "reviews"."stars" = $1 [["stars", 4]]

15 / 38
. Fetch all acomodations with reviews of 4 stars (not in average), preloading reviews (with preload)

Accomodation.joins(:reviews).where(reviews: {stars:
4}).distinct(true).preload(:reviews)

SELECT DISTINCT "accomodations".* FROM "accomodations" INNER JOIN "reviews"


ON "reviews"."accomodation_id" = "accomodations"."id" WHERE "reviews"."stars"
= $1 [["stars", 4]]
SELECT "reviews".* FROM "reviews" WHERE "reviews"."accomodation_id" IN ($1,
$2) [[nil, 2], [nil, 4]]

. Fetch all acomodations with reviews of 4 stars (not in average), preloading reviews (with eager_load)

Accomodation.eager_load(:reviews).where(reviews: {stars: 4})

SELECT "accomodations"."id" AS t0_r0, "accomodations"."name" AS t0_r1,


"accomodations"."bathrooms_count" AS t0_r2, "accomodations"."guests_count" AS
t0_r3, "reviews"."id" AS t1_r0, "reviews"."accomodation_id" AS t1_r1,
"reviews"."body" AS t1_r2, "reviews"."stars" AS t1_r3 FROM "accomodations"
LEFT OUTER JOIN "reviews" ON "reviews"."accomodation_id" =
"accomodations"."id" WHERE "reviews"."stars" = $1 [["stars", 4]]

. List of accomodations with reviews of 4 stars (in average), preloading reviews

Accomodation.joins(:reviews).group(:id).having("avg(reviews.stars) >
4").preload(:reviews)

SELECT "accomodations".* FROM "accomodations" INNER JOIN "reviews" ON


"reviews"."accomodation_id" = "accomodations"."id" GROUP BY
"accomodations"."id" HAVING (avg(reviews.stars) >= 4)
SELECT "reviews".* FROM "reviews" WHERE "reviews"."accomodation_id" IN ($1,
$2, $3) [[nil, 4], [nil, 2], [nil, 3]]

16 / 38
. List of accomodations with exactly 2 rooms, preloading rooms

Accomodation.joins(:rooms).group(:id).having("count(rooms.id) =
2").preload(:rooms)

SELECT "accomodations".* FROM "accomodations" INNER JOIN "rooms" ON


"rooms"."accomodation_id" = "accomodations"."id" GROUP BY
"accomodations"."id" HAVING (count(rooms.id) = 2)
SELECT "rooms".* FROM "rooms" WHERE "rooms"."accomodation_id" IN ($1, $2, $3,
$4) [[nil, 4], [nil, 2], [nil, 3], [nil, 1]]

17 / 38
Understanding when the query will be executed
Imagine that you have a method that works fine with a single record, but when you use it on a list causes N+1
queries.
Like in the next model…

class Posts
has_many :comments

def latest_comment
comments.order(:created_at).last
end
end

When you are fetching a single post there is no problem…

post = Post.find(id)
puts post.latest_comment

But when you try to fetch a list of posts, the method seems to ignore the includes, and runs a query for each
post to get the latest_comment for each post!

Post.includes(:comments).each do |post|
puts post.latest_comment
end

Why?
Well, let's start by understanding method chaining in Active Record.
Method chaining in Active Record
Active Record implements "method chaining" which allow us tu use multiple Active Record methods together.
You can chain methods in a statement when the previous method returns an ActiveRecord::Relation, like
all, where, includes, joins and order.

You can't chain Active Record methods, after a method that does not return an ActiveRecord::Relation,
like to_a, find or last. You need to put those methods at the end of the statement.
18 / 38
Note: You can learn more on the rails guides
What is the problem in the example?
When you are fetching a single post there is no problem, because all the methods before last return an
ActiveRecord::Relation

post = Post.find(id)
puts post.latest_comment

If you try each link of the chain, you will see that just the call to last does not return an
ActiveRecord::Relation.

relation = ActiveRecord::Relation
comments = Post.find(id).comments

puts comments.is_a?(relation) #=> true


puts comments.order(:id).is_a?(relation) #=> true
puts comments.order(:id).last.is_a?(relation) #=> false

But when you try to fetch the list…

Post.includes(:comments).each do |post|
puts post.latest_comment
end

At the moment you call each on the ActiveRecord::Relation, it will execute the query, and in your logs
you will see something like this...

Post Load (0.2ms) SELECT "posts".* FROM "posts"


Comment Load (0.4ms) SELECT "comments".* FROM "comments" WHERE
"comments"."post_id" IN ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) ORDER BY
"comments"."id" ASC [["post_id", 2001], ["post_id", 2002], ["post_id",
2003], ["post_id", 2004], ["post_id", 2005], ["post_id", 2006], ["post_id",
2007], ["post_id", 2008], ["post_id", 2009], ["post_id", 2010]]

But then for each post it will execute a new query, because although you already have the comments loaded,
with order(:created_at).last you are building a new query to fetch the latest comment with a different
order.

19 / 38
That is why when you try to fetch a list of posts, the method seems to ignore the includes, and runs a query
for each post to get the latest_comment.
How can you solve this problem?
You can solve this problem and other similar problems where you are also trying to fetch the latest "X" in a list
of records using an association with a default order.
Something like this…

class Post
has_many :comments, -> { order(:created_at) }

def latest_comment
comments.last
end
end

Post.includes(:comments).each do |post|
puts post.latest_comment
end

It won't execute n+1 queries, because you are fetching the comments already on the right order, and you don't
need to ask the database to sort them again.
If you want to see other options you can see this post with some other ways to solve the problem.

20 / 38
Practice the identification of when the query will be
executed
Now that you understand when the query will be execute, is time to practice this ability and try to make it
automatic.
This exercises will try to help you identify when a query will execute by just watching the code!
Instructions
I will show you 10 code examples using ActiveRecord's query interface, like includes, order and where.
For each code example you will:
. Try to identify when ActiveRecord will execute de query
. Compare your answer with the one I will provide after the example.
Working environment
To help you really work on this examples I have prepared a git repo with the setup that you need to run the
examples.
You can use it to run the examples, and play with the code to explore more.
You can find the repo here: github.com/bhserna/when_the_query_is_executed
There you will find the instructions to run the project.

21 / 38
Exercises
Exercise 1
Post.includes(:comments).to_a

Exercise 2
Post.includes(:comments).order(:id).to_a

Exercise 3
Post.all.each do |post|
post.comments
post.sorted_comments.to_a
end

Exercise 4
Post.includes(:comments).each do |post|
post.comments.to_a
end

Exercise 5
Post.includes(:comments).each do |post|
post.comments.order(:id)
post.comments.order(:id).to_a
end

22 / 38
Exercise 6
Post.includes(:sorted_comments).each do |post|
post.sorted_comments.to_a
post.comments.to_a
end

Exercise 7
Post.includes(:sorted_comments).each do |post|
post.sorted_comments.first
post.comments.first
end

Exercise 8
Post.includes(:sorted_comments).map do |post|
post.sorted_comments.last
post.comments.last
end

Exercise 9
Post.includes(:sorted_comments).sort_by(&:id).each do |post|
post.sorted_comments.limit(1).first
post.comments.to_a.limit(1).first
end

Exercise 10
Post.includes(:comments).where("comments.points >
5").references(:comments).each do |post|
post.comments.to_a
post.comments.order(:id).to_a
post.comments.sort_by(&:id).to_a
end

23 / 38
Answers
Answer 1
Post.includes(:comments).to_a

When calling to_a


Answer 2
Post.includes(:comments).order(:id).to_a

When calling to_a


Answer 3
Post.all.each do |post|
post.comments
post.sorted_comments.to_a
end

When calling each on Post.all.each


When calling to_a on post.sorted_comments
Answer 4
Post.includes(:comments).each do |post|
post.comments.to_a
end

When calling each on Post.includes(:comments).each

24 / 38
Answer 5
Post.includes(:comments).each do |post|
post.comments.order(:id)
post.comments.order(:id).to_a
end

When calling each on Post.includes(:comments).each


When calling to_a on post.comments.order(:id).to_a
Answer 6
Post.includes(:sorted_comments).each do |post|
post.sorted_comments.to_a
post.comments.to_a
end

When calling each on Post.includes(:sorted_comments).each


When calling to_a on post.comments.to_a
Answer 7
Post.includes(:sorted_comments).each do |post|
post.sorted_comments.first
post.comments.first
end

When calling each on Post.includes(:sorted_comments).each


When calling first on post.comments.first
Answer 8
Post.includes(:sorted_comments).map do |post|
post.sorted_comments.last
post.comments.last
end

When calling map on Post.includes(:sorted_comments).map


When calling last on post.comments.last
25 / 38
Answer 9
Post.includes(:sorted_comments).sort_by(&:id).each do |post|
post.sorted_comments.limit(1).first
post.comments.to_a.limit(1).first
end

When calling sort_by on Post.includes(:sorted_comments).sort_by(&:id).each


When calling first on post.sorted_comments.limit(1).first
When calling first on post.comments.to_a.limit(1)
Answer 10
Post.includes(:comments).where("comments.points >
5").references(:comments).each do |post|
post.comments.to_a
post.comments.order(:id).to_a
post.comments.sort_by(&:id).to_a
end

When calling each on Post.includes(:comments).where("comments.points >


5").references(:comments).each
When calling to_a on post.comments.order(:id).to_a

26 / 38
Tools to help you detect n+1 queries
This is a little reference of tools to help you detect n+1 queries on a rails app.
Bullet
Prosopite
NPlusOneControl
Rails strict_loading
Rack mini profiler
Bullet
How can it help you?
It will watch your queries while you develop your application and notify you when it detects a problem.
It can detect:
n+1 queries
eager-loaded associations which are not used
unnecessary COUNT queries which could be avoided with a counter cache
You can use it on the development and testing environments.
Sometimes Bullet may notify you of query problems you don't care to fix, or which come from outside your
code. You can whitelist these to ignore them.
How does the error report looks like?
For n+1 queries...

GET /posts
USE eager loading detected
Post => [:comments]
Add to your query: .includes([:comments])
Call stack
/Users/benitoserna/code/bullet-test/app/views/posts/index.html.erb:20:in
`map'
/Users/benitoserna/code/bullet-test/app/views/posts/index.html.erb:20:in
`block in
_app_views_posts_index_html_erb__1178069968615334744_70147771830640'
/Users/benitoserna/code/bullet-test/app/views/posts/index.html.erb:16:in
`_app_views_posts_index_html_erb__1178069968615334744_70147771830640'

For unused eager loading...


27 / 38
GET /posts
AVOID eager loading detected
Post => [:comments]
Remove from your query: .includes([:comments])
Call stack

How do you use it?


Add it to your application's Gemfile and run bundle install:

group :development, :test do


gem "bullet"
end

Enable the Bullet gem with the command

bin/rails g bullet:install

This will help you configure bullet on the development and test enviroments.
References
github/flyerhzm/bullet
Prosopite
How can it help you?
Like Bullet, Prosopite is able to auto-detect Rails N+1 queries, but it also can help you detect some cases
where bullet will give you false positives or false negatives.
Prosopite monitors all SQL queries using the Active Support instrumentation and looks for a pattern which is
present in all N+1 query cases: More than one queries have the same call stack and the same query fingerprint.
Compared to bullet, Prosopite can auto-detect the following extra cases of N+1 queries:

28 / 38
N+1 queries after record creations (usually in tests)

FactoryBot.create_list(:leg, 10)

Leg.last(10).each do |l|
l.chair
end

Not triggered by ActiveRecord associations

Leg.last(4).each do |l|
Chair.find(l.chair_id)
end

First/last/pluck of collection associations

Chair.last(20).each do |c|
c.legs.first
c.legs.last
c.legs.pluck(:id)
end

Changing the ActiveRecord class with #becomes

Chair.last(20).map{ |c| c.becomes(ArmChair) }.each do |ac|


ac.legs.map(&:id)
end

29 / 38
Mongoid models calling ActiveRecord

class Leg::Design
include Mongoid::Document
...
field :cid, as: :chair_id, type: Integer
...
def chair
@chair ||= Chair.where(id: chair_id).first!
end
end

Leg::Design.last(20) do |l|
l.chair
end

How does the error report looks like?


The report will show you the N+1 queries detected and the call stack.

N+1 queries detected:


SELECT `users`.* FROM `users` WHERE `users`.`id` = 20 LIMIT 1
SELECT `users`.* FROM `users` WHERE `users`.`id` = 21 LIMIT 1
SELECT `users`.* FROM `users` WHERE `users`.`id` = 22 LIMIT 1
SELECT `users`.* FROM `users` WHERE `users`.`id` = 23 LIMIT 1
SELECT `users`.* FROM `users` WHERE `users`.`id` = 24 LIMIT 1
Call stack:
app/controllers/thank_you_controller.rb:4:in `block in index'
app/controllers/thank_you_controller.rb:3:in `each'
app/controllers/thank_you_controller.rb:3:in `index':
app/controllers/application_controller.rb:8:in `block in
<class:ApplicationController>'

How do you use it?


Add this line to your application's Gemfile:

gem 'prosopite'

30 / 38
And then execute:

$ bundle install

Development environment
Prosopite auto-detection can be enabled on all controllers with...

class ApplicationController < ActionController::Base


unless Rails.env.production?
before_action do
Prosopite.scan
end

after_action do
Prosopite.finish
end
end
end

And the preferred notification channel should be configured:

# config/environments/development.rb
config.after_initialize do
Prosopite.rails_logger = true
end

Test environment
Tests with N+1 queries can be configured to fail with:

# config/environments/test.rb
config.after_initialize do
Prosopite.rails_logger = true
Prosopite.raise = true
end

31 / 38
And each test can be scanned with:

# spec/spec_helper.rb
config.before(:each) do
Prosopite.scan
end

config.after(:each) do
Prosopite.finish
end

References
github/charkost/prosopite
NPlusOneControl
How can it help you?
It gives you rspec and minitest matchers to prevent the n+1 queries problem.
It evaluates the code under consideration several times with different scale factors to make sure that the
number of DB queries behaves as expected (i.e. O(1) instead of O(N)).
So, it's for performance testing and not feature testing.
How does the error report looks like?
In the default mode it can give you something like this.

Expected to make the same number of queries, but got:


10 for N=2
11 for N=3
Unmatched query numbers by tables:
resources (SELECT): 2 != 3
permissions (SELECT): 4 != 6

32 / 38
And in the "verbose" mode, it can give you something like this...

Expected to make the same number of queries, but got:


2 for N=2
3 for N=3
Unmatched query numbers by tables:
resources (SELECT): 2 != 3
Queries for N=2
SELECT "resources".* FROM "resources" WHERE "resources"."deleted_at" IS
NULL
↳ app/controllers/resources_controller.rb:32:in `index'
...
Queries for N=3
...

How do you install it?


Add it to your application's Gemfile and run bundle install:

group :test do
gem "n_plus_one_control"
end

How do you use it?


First, add NPlusOneControl to your spec_helper.rb:

require "n_plus_one_control/rspec"

33 / 38
Then:

# Wrap example into a context with :n_plus_one tag


context "N+1", :n_plus_one do
# Define `populate` callbacks which is responsible for data
# generation (and whatever else).
#
# It accepts one argument – the scale factor (read below)
populate { |n| create_list(:post, n) }

specify do
expect { get :index }.to perform_constant_number_of_queries
end
end

References
github/palkan/n plus one control
Squash N+1 queries early with n plus one control test matchers for Ruby and Rails
Rails strict_loading
How it can help you?
You can add #strict_loading! to any record to prevent lazy loading of associations. Strict will cascade
down from the parent record to all the associations to help you catch any places where you may want to use
preload instead of lazy loading.
How do you use it?
On a record...

user = User.first
user.strict_loading!
user.comments.to_a
=> ActiveRecord::StrictLoadingViolationError

On a relation...

user = User.strict_loading.first
user.comments.to_a
=> ActiveRecord::StrictLoadingViolationError

34 / 38
References
ActiveRecord::Core
ActiveRecord::QueryMethods
Pull request: Add strict_loading mode to optionally prevent lazy loading
Rack mini profiler
How it can help you?
It can help you with more than just detecting n+1 queries!... It is a production and development profiler, it allows
you to quickly isolate performance bottlenecks, both on the server and client.
It can help you with...
Database profiling
Call-stack profiling
Memory profiling
How does the report looks like?
It displays a speed badge for every html page that if you click it, it will show you a page with the profiling
information of the current page.

And if you click on of the sql queries count, it will show you a list with all the queries...

35 / 38
Although it will not tell you exactly that you have an n+1 quieries problem, it can help you a lot to visualize it.
How do you use it?
On development
In rails all you have to do is to include the Gem and you're good to go in development.
Install/add to Gemfile in Ruby 2.4+ (The gem is added by default to rails since version 6.1)

gem 'rack-mini-profiler'

NOTE: Be sure to require rack_mini_profiler below the pg and mysql gems in your Gemfile.
rack_mini_profiler will identify these gems if they are loaded to insert instrumentation. If included too
early no SQL will show up.
You can also include optional libraries to enable additional features.

# For memory profiling


gem 'memory_profiler'

# For call-stack profiling flamegraphs


gem 'stackprof'

36 / 38
On production
It is designed with production profiling in mind. To enable that run
Rack::MiniProfiler.authorize_request once you know a request is allowed to profile.

# inside your ApplicationController

before_action do
if current_user && current_user.is_admin?
Rack::MiniProfiler.authorize_request
end
end

References
github/MiniProfiler/rack-mini-profiler
The announcement posts from 2012
Conclusion
As you can see, each tool can help you in different ways, some of them could be of more value to you than
others, but that is not a problem, because you can use them in combination if you need it.

37 / 38
Final notes
You made it! Congrats!
Let's recap what you can do now...
Now you can fix a lot of n+1 queries problems and find efficient solutions by yourself
You are fluent with some of the most used methods of ActiveRecord
You can identify when the query will be execute and be faster to identify how to fix an n+1 problem
You what tools can help your team to avoid n+1 queries and how to use them
Now you can help and teach your team how to avoid n+1 queries
I hope that you can feel that now you are a better rails developer!
I hope you have enjoyed this book!
If you have some feedback, please write me to [email protected]

38 / 38

You might also like