You ask the database for a list of authors. One query. Then, for each author, you ask for their books. One query per author. For 200 authors that is 201 round trips to do what one join would have done.
N+1 = 1 query for the parents + N queries for the children.
The code looks innocent. The log does not.
What it looks like
select * from author; -- 1
select * from book where author_id = 1; -- +1
select * from book where author_id = 2; -- +1
select * from book where author_id = 3; -- +1
... -- N times
Each query is fast. There are just N of them, and every one is a network round trip. It works in dev with 5 rows and falls over in production with 5,000.
Why it happens
An ORM maps a relation to a field. The field is lazy: touching it runs a query. A loop over the parents touches the field once per parent, and the ORM does exactly what it was told, one parent at a time.
JPA / Hibernate
@Entity
class Author {
@Id Long id;
String name;
@OneToMany(mappedBy = "author") // lazy by default
List<Book> books;
}
List<Author> authors = authorRepository.findAll(); // 1 query
for (Author author : authors) {
author.getBooks().size(); // +1 query, per author
}
Nothing here says "query" except findAll. The loop is the N.
Rust / Diesel
Diesel has no lazy loading, so it will not run a hidden query for you. You write the N+1 yourself, and it looks like this:
let authors: Vec<Author> = authors::table.load(conn)?; // 1 query
for author in &authors {
let books: Vec<Book> = Book::belonging_to(author).load(conn)?; // +1 query, per author
}
Same shape, same cost. The difference is that in Diesel the query is visible in the code, so a reviewer can see it.
The fix
Fetch the children for all parents at once. Two ways: one join, or two queries.
JPA: join fetch
@Query("select a from Author a join fetch a.books")
List<Author> findAllWithBooks(); // 1 query, with a join
Or the same thing without JPQL:
@EntityGraph(attributePaths = "books")
List<Author> findAll();
One query, one result set, Hibernate assembles the objects. Use distinct or a Set if the join duplicates parent rows.
When you cannot join, for example with pagination, where a fetch join would load the whole table into memory, use batching instead:
@OneToMany(mappedBy = "author")
@BatchSize(size = 50)
List<Book> books; // N queries become N/50: where author_id in (?, ?, ... 50 ids)
Diesel: belonging_to on the whole list
let authors: Vec<Author> = authors::table.load(conn)?; // 1 query
let books: Vec<Book> = Book::belonging_to(&authors).load(conn)?; // 1 query: where author_id in (...)
let books_per_author: Vec<Vec<Book>> = books.grouped_by(&authors);
let result: Vec<(Author, Vec<Book>)> = authors.into_iter().zip(books_per_author).collect();
Pass the whole Vec to belonging_to instead of one author, and Diesel emits a single in (...) query. grouped_by puts each book back under its author in memory. Two queries total, whatever N is.
Or a plain join when you want flat rows:
let rows: Vec<(Author, Book)> = authors::table
.inner_join(books::table)
.load(conn)?; // 1 query
How to catch it
- Read the log. Hibernate:
spring.jpa.show-sql=true, orhibernate.generate_statistics=trueand look at the query count per request. Diesel:diesel::debug_queryon the statement, or statement logging on the connection. - Count queries in a test. Load a page with 50 rows and assert the statement count is 1 or 2, not 51. The test fails the day someone adds a lazy field to the loop.
- Watch the database.
pg_stat_statementsranks queries by call count. An N+1 shows up as a trivial query with an absurd number of calls.
If the number of queries grows with the number of rows, it is an N+1.
Fetch the children for all parents in one go: a join, or an in (...) list.
Brief
One query loads the parents, then one query per parent loads its children, so the query count grows with the row count. Fix it by fetching the children for all parents at once, with a fetch join or an in list, and prove it by counting queries in the log.
References
Videos:
- JOIN FETCH with Hibernate: the end of n+1 select issues, Thorben Janssen
- Fix N+1 select issues with Hibernate's EntityGraphs, Thorben Janssen
- Spring Boot N+1 query problem explained and fixed with Hibernate, MOHANED
Docs:
- Diesel guide: relations, the
belonging_toandgrouped_bypattern - Diesel
associationsmodule