Diagnosing and Fixing N+1 Lazy-Loading Issues in Grails 4–6 Applications
Detect and eliminate Grails N+1 lazy-loading problems. Use SQL logging, mapping tweaks, join fetches, DTOs, and batch size to cut query counts and improve response times.
20 Jul 2025, 12:37 UTC

Recognizable Condition
When a controller action such as BookController.list() returns a JSON array, you notice the response time is several seconds and the logs show dozens of SELECT statements executed for each Book. The symptom is a single domain query that triggers a cascade of additional queries for related entities (e.g., Author, Publisher, Collection). This is classic N+1 query behavior caused by Grails’ default lazy loading.
Root-Cause Table
| Cause | Description |
|---|---|
Default FetchType.LAZY |
GORM maps @OneToMany and @ManyToOne as lazy, so accessing the property triggers a separate query. |
| Open Session in View (OSIV) | The session stays open across the view rendering, but it does not pre-fetch data; it merely allows lazy loading to happen. |
| JSON rendering outside transaction | When render bookInstance as JSON traverses the object graph, it does so after the service method has completed, still inside the OSIV session. |
| Missing batch or subselect fetch | Hibernate can batch collection loads, but only if @BatchSize or @Fetch(FetchMode.SUBSELECT) is defined. |
Ordered Verification Checks
- Enable SQL logging – add
logSql = truetoDataSource.groovyor sethibernate.show_sql=true. Run the action once and count theSELECTstatements. More than 10 queries for a singlelist()call confirms an N+1 problem.// application.yml or DataSource.groovy hibernate: show_sql: true format_sql: true generate_statistics: true - Inspect domain mappings – look for
fetch:'join',batchSize, orlazy:false. If none are present, the default lazy behavior applies.// Book.groovy class Book { static hasMany = [authors: Author] static mapping = { // No fetch or batchSize specified – default lazy } } - Check transaction boundaries – ensure the service method is annotated
@Transactionalor the controller useswithTransaction. The Hibernate session must span the entire graph walk.@Transactional class BookService { def listBooks() { Book.list() } } - Verify OSIV state – the Grails default
grails.gorm.openSessionInViewflag is true. Disabling it without adjusting transaction scopes will produceLazyInitializationException.
Targeted Fixes
Choose the fix that matches the root cause you identified.
1. Static Mapping with Join Fetch
Add a fetch:'join' to the owning side. This forces Hibernate to use a LEFT OUTER JOIN for the association, eliminating the separate query for each item.
// Book.groovy
class Book {
static hasMany = [authors: Author]
static mapping = {
authors fetch:'join', batchSize:20
}
}
Risk: Limit fetch:'join' to a single collection per query to avoid Cartesian products (where the result set grows exponentially). If you need to load multiple collections, use batchSize instead.
2. Query-Level Join Fetch
When you cannot modify the domain class, use a criteria or HQL query that specifies a join fetch.
// BookService.groovy
import org.hibernate.FetchMode
Book.createCriteria().list {
fetchMode('authors', FetchMode.JOIN)
}
This approach is useful for one-off actions like an API endpoint that needs eager loading for a specific response without affecting the rest of the application.
3. DTO Projection
Instead of serializing the full domain instance, project only the fields you need. This bypasses GORM events but reduces the object graph size and prevents lazy loading triggers.
// BookService.groovy
def listSummaries() {
Book.createQuery(
"select b.id as id, b.title as title, a.name as authorName from Book b join b.authors a"
).list()
}
4. Batch Fetching Collections
If you keep lazy loading but want to reduce the number of queries, enable batch fetching. This loads multiple proxies in a single query using an IN clause.
// Book.groovy
class Book {
static hasMany = [authors: Author]
static mapping = {
authors batchSize:20
}
}
Ensure hibernate.default_batch_fetch_size is not set to 0 in the configuration.
Escalation Criteria
- If the query count remains >50 per request after applying these fixes, the problem likely lies in a deep object graph that requires a data model redesign.
- Repeated
LazyInitializationExceptionafter disabling OSIV indicates that transaction boundaries are not correctly defined; audit service annotations. - Performance regressions (e.g., response time > 500ms) after adding
fetch:'join'on large collections may necessitate the use ofbatchSizeor a second-level cache.
Verification: Run the application with logSql=true and a single list action; confirm the query count drops from N+1 to 1–3. For Grails 6 users, verify that Hibernate 6 join fetch semantics behave as expected in your specific version.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.