Choosing the Right GORM Query Style in Grails 6: Where Queries vs Criteria vs Dynamic Finders
Grails 6 teams choosing between Where queries, Criteria API, and dynamic finders: Where queries win for new code with compile-time safety and composability, Criteria stays for advanced projections, dynamic finders only for trivial lookups.
05 Jul 2026, 03:03 UTC

The query style decision you make today shapes refactoring pain tomorrow
Every Grails team eventually faces the same question: which GORM query approach should be the default for new code? Dynamic finders feel magical at first. Criteria API looks familiar if you came from Hibernate. Where queries sit somewhere in between — type-safe but with a learning curve. The choice isn't academic; it determines how easily you can rename a domain property six months from now without hunting through string literals.
Dynamic finders: convenient until they aren't
Dynamic finders (findBy*, findAllBy*) generate methods at runtime via methodMissing. For a Person domain with firstName, lastName, age, and active fields, you get findByFirstName, findByFirstNameAndLastName, findByAgeGreaterThan, and dozens more permutations. With ten searchable fields, the metaClass swells to over a thousand methods. In pre-Java 8 JVMs this bloated PermGen; on modern JVMs it still increases Metaspace pressure and startup time.
The real limitation appears when you need OR logic or nested conditions. You can't express firstName == 'A' && (age > 18 || active == true) with a single dynamic finder. You end up chaining multiple finders in memory or falling back to Criteria anyway. Reserve dynamic finders for one- or two-property equality lookups in controllers or simple services — anything beyond that belongs elsewhere.
Criteria API: power with a refactoring tax
Criteria uses a builder pattern with nested closures:
def results = Person.createCriteria().list {
eq('firstName', 'A')
or {
gt('age', 18)
eq('active', true)
}
order('lastName', 'asc')
}
This handles projections, associations, subqueries, and sqlProjection cleanly. But property names are strings. Rename firstName to givenName in the domain class and the compiler won't warn you — the query fails at runtime. IDE refactoring tools miss these strings. For legacy codebases or advanced projections (countDistinct, sqlGroupProjection), Criteria remains necessary. For new repository methods, it's a maintenance liability.
Where queries: the default for new code
Where queries (introduced in Grails 2.3, refined through 3.x and now the documented default in Grails 6/GORM 7+) use an AST-transformed closure that becomes a type-safe predicate at compile time:
def query = Person.where {
firstName == 'A' && (age > 18 || active == true)
}
List<Person> results = query.list()
The closure isn't a runtime Groovy closure — it's transformed during compilation. This means:
- Property names (
firstName,age,active) are validated against the domain class at compile time. Typos surface as compilation errors, not runtime exceptions. - IDE autocompletion works inside the closure.
- The query object is detachable:
Person.where { age > 18 } as DetachedCriteriacan be passed around, composed, and reattached to a session later. - No
methodMissinginterception or closure-delegate overhead — marginally faster startup and lower memory under high throughput.
Worked example: a repository method with composable filters
Consider a PersonRepository service that builds a query from optional search parameters:
@Transactional
class PersonRepository {
List<Person> search(String name, Integer minAge, Boolean activeOnly) {
def query = Person.where {}
if (name) {
query = query.where { firstName ==~ "%${name}%" || lastName ==~ "%${name}%" }
}
if (minAge) {
query = query.where { age >= minAge }
}
if (activeOnly) {
query = query.where { active == true }
}
query.list()
}
}
Each where call returns a new query instance, enabling fluent composition. The generated SQL uses parameter binding — enable hibernate.show_sql: true and hibernate.format_sql: true in application.yml to verify. Run ./gradlew compileGroovy and a typo like fristName fails compilation immediately.
Trade-offs and pitfalls to know
Where queries aren't without constraints:
- AST transformation limits: The closure only supports a subset of Groovy syntax — no arbitrary method calls, loops, or side effects. Violations produce cryptic compile-time errors. Keep logic simple; delegate complex calculations to helper methods outside the closure.
- DetachedCriteria session attachment: A detached query from
Person.where { age > 18 } as DetachedCriteriamust be reattached to a Hibernate session via.list(session)or executed within a@Transactionalmethod. Calling.list()outside a transaction throws "no session" errors — a common pitfall in service methods missing the annotation. - Property name coupling: Compile-time checking only works against the domain class. DTOs, command objects, or projection results don't participate. If you project into a DTO, property names in the Where closure still reference the domain, not the DTO.
- Hibernate 6 migration: Grails 6.1+ uses Hibernate 6. Some Criteria projections (
sqlGroupProjection,sqlProjection) changed signatures. Where queries are largely unaffected, but verify generated SQL during upgrade.
Actionable migration strategy
- Default to Where queries for all new repository methods and service queries.
- Keep Criteria only for advanced projections (
countDistinct,sqlProjection) or when porting legacy Hibernate Criteria code that would be risky to rewrite. - Replace dynamic finders exceeding two properties with Where equivalents during routine maintenance.
- Verify with SQL logging: Add
hibernate.show_sql: trueandhibernate.format_sql: truetoapplication.yml, run integration tests, and compare generated SQL across styles — they should be nearly identical. - Confirm Hibernate version: Run
./gradlew dependencyInsight --configuration compileClasspath --dependency hibernate-coreto ensure you're on 6.x in Grails 6.1+.
The compile-time safety of Where queries pays off the first time a domain property rename breaks zero queries instead of five. Start there.