Choosing Grails GORM Fetch Strategies for hasMany Associations
Decide between lazy, eager select, and eager join fetching for Grails GORM hasMany associations while avoiding N+1 queries, duplicates, and pagination errors.
20 Jan 2026, 07:29 UTC

Decision: how Grails should fetch related instances
When a Grails controller loads a list of domain instances and the view iterates a hasMany association, the default GORM behavior loads related rows only when they are accessed. That lazy loading often produces one extra SQL query per parent row—the N+1 problem. Switching to eager fetching removes those extra queries but can duplicate parent rows, multiply result sets, or break pagination counts. This guide helps you choose between lazy, eager select, and eager join fetching for hasMany and belongsTo associations in Grails applications using GORM/Hibernate.
Constraints and assumptions
- Grails 3 through 6 with GORM 7.x or 8.x. Exact batch-fetch defaults and open-session-in-view behavior differ between releases.
- You are tuning read-heavy list or show pages, not write-only batch jobs.
- You can inspect SQL output and run integration tests against a database copy with realistic volume.
Supported fetch options
| Mapping | When it runs | Duplicates parents? | Round trips | Best for |
|---|---|---|---|---|
lazy: true (default) | First access to the collection | No | One per accessed association | Sparse access, safe pagination |
lazy: false | Immediately with the parent | No | One extra query per association | Always-needed single association |
fetchMode: 'join' | Single SQL join with parent | Yes, for collections | One query | Single-valued associations, small graphs |
fetchMode: 'select' | Secondary query after parent load | No | One per association | Multiple collections where joins would multiply rows |
Trade-offs
Lazy loading is the safe default
Lazy loading means Hibernate does not issue SQL for the associated table until your code calls author.getBooks() or renders ${author.books} in a GSP. If a page lists 50 authors and each has books, the view can trigger 51 queries. That is the N+1 problem: one query for parents plus one per parent. It also means accessing the collection after the Hibernate session closes throws LazyInitializationException in configurations without open-session-in-view; Grails OSIV behavior varies by version.
Eager select avoids duplicates
lazy: false or fetchMode: 'select' loads the children with a separate SELECT immediately after loading the parent. It avoids duplicate parent rows, but each association still costs a round trip. With several associations, you trade N+1 for multiple fixed queries per page load.
Eager join is one query, but risky with collections
fetchMode: 'join' issues a single SQL JOIN. For a belongsTo or single-valued association this is usually efficient. For a hasMany collection, the join multiplies parent rows: one author with ten books produces ten result rows containing the same author. Hibernate deduplicates in memory, but the database still processes more rows. Joining two or more eager collections can create a Cartesian product, returning far more rows than expected. Pagination is especially affected: Hibernate applies max and offset to the multiplied join result, so the returned page may contain fewer distinct parents than requested.
Example: Author and Book domain classes
Assume two domain classes in grails-app/domain/:
class Author {
String name
static hasMany = [books: Book]
static mapping = {
books lazy: false
}
}
class Book {
String title
Author author
static belongsTo = [author: Author]
}
With books lazy: false, loading Author.get(1) issues a second SELECT for books immediately. Change it to join fetching like this:
static mapping = {
books fetchMode: 'join'
}
Do not apply fetchMode: 'join' to multiple hasMany collections on the same domain class unless you have verified the generated SQL under realistic data volumes.
Verify with SQL logging
Make this change in the application root. No special OS permissions are required beyond read/write access to the project files.
- Open
application.yml. - Enable SQL logging:
dataSource:
logSql: true
logging:
level:
org.hibernate.SQL: DEBUG
org.hibernate.type.descriptor.sql.BasicBinder: TRACE
Start the application and load a parent instance, then iterate its collection in a controller or GSP. Expected check: the SQL log should show one query for the parent and either zero additional queries (eager join/select) or one additional query per accessed parent (lazy). Risk: leaving logSql: true and binder tracing enabled in production degrades performance and can write sensitive parameter values to logs.
Integration test skeleton
Create a test under src/integration-test/groovy/ or your project's integration test source set. Run it with the Grails test runner from the project root. This skeleton is not a claim that it passed in your environment.
import grails.test.mixin.integration.Integration
import spock.lang.Specification
@Integration
class AuthorFetchSpec extends Specification {
void "eager join loads author and books in one query"() {
given:
new Author(name: "A").addToBooks(new Book(title: "B")).save(flush: true)
when:
def author = Author.get(1)
author.books.size()
then:
// Assert against recorded SQL statement count in your test harness.
// There is no universal constant; establish a baseline before changing mapping.
author.books.size() == 1
}
}
A practical comparison is to run the same test with lazy: true, lazy: false, and fetchMode: 'join', then count SQL statements in the log. Use a database copy with realistic row counts; local results with ten rows do not predict production behavior.
Limitations
- Do not set
lazy: falseon every association. Large object graphs increase memory pressure and slow queries. - Pagination with eager
hasManycan return fewer distinct parents than the requested page size because joined rows are counted before distinct results are materialized. - Changing static mapping after deployment changes generated SQL. Revert the mapping file and redeploy to roll back the behavior change.
- GORM 7.x and 8.x differ in default fetch profiles and batch-fetching support; verify the exact generated SQL for your Grails version.
Practical check
After changing a mapping, load a representative parent list in a staging environment, inspect logSql output, and confirm query count and row counts match expectations. Compare page render time before and after only under realistic data volume; microbenchmarks with small datasets can favor the wrong strategy.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.