Paginating GORM Queries in Grails: Controllers, GSP Views, and the g:paginate Tag
A safe, complete pattern for GORM pagination in Grails: sanitize max/offset/sort in the controller, render with g:paginate, and verify LIMIT/OFFSET in the SQL.
22 Jul 2026, 07:28 UTC

You have a Grails controller action returning every row of a table, and the page is getting slower as data grows. The fix is GORM's built-in pagination: pass max and offset to list(), count the total records, and render navigation with the <g:paginate> tag. Done carelessly, though, this pattern opens you to unbounded queries, negative offsets, and sort-parameter injection. This guide covers the safe version of the whole flow.
The examples assume Grails 4/5/6 with GORM for Hibernate. The behavior described here (the list(max:offset:) signature and the paginate tag) has been stable across those versions, but confirm against your version's documentation before shipping.
What you need before starting
- A Grails application with at least one domain class mapped to a table with enough rows to be worth paging (a few hundred makes testing meaningful).
- A controller action that currently calls something like
Book.list()unfiltered. - A GSP view rendering the results, typically with an iteration tag such as
<g:each>.
Step 1: Sanitize pagination parameters in the controller
Grails exposes the query string through the params object. The two parameters that matter are max (page size) and offset (how many rows to skip). Never pass them straight through: a missing or malformed value can produce an unbounded query or a database error.
class BookController {
static allowedMethods = [index: 'GET']
private static final int DEFAULT_MAX = 20
private static final int HARD_MAX = 100
def index() {
int max = Math.min(params.int('max') ?: DEFAULT_MAX, HARD_MAX)
int offset = Math.max(params.int('offset') ?: 0, 0)
String sort = params.sort in ['title', 'author', 'datePublished'] ? params.sort : 'title'
String order = params.order == 'desc' ? 'desc' : 'asc'
def books = Book.list(max: max, offset: offset, sort: sort, order: order)
int total = Book.count()
[bookList: books, bookCount: total]
}
}
Three decisions here matter:
- Cap
max. A request with?max=10000000will happily load that many rows into memory. Enforce a hard ceiling regardless of what the client asks for. - Clamp
offsetat zero. Negative offsets cause SQL errors on some databases and silently odd behavior on others. - Whitelist
sort. Thesortstring becomes part of the generated query. Do not accept arbitrary input; compare it against a list of real, indexed property names and fall back to a default.
params.int('max') returns null when the parameter is absent or non-numeric, which is why the Elvis operator (?:) supplies defaults.
Step 2: Render the list and the pagination controls
In grails-app/views/book/index.gsp, iterate the results and add the <g:paginate> tag below them:
<g:each in="${bookList}" var="book">
<p>${book.title} — ${book.author}</p>
</g:each>
<div class="pagination">
<g:paginate total="${bookCount ?: 0}"
max="${params.max ?: 20}"
params="${[sort: params.sort, order: params.order]}" />
</div>
The tag needs total (the full record count, not the page size) so it can compute how many page links to draw. It reads offset and max from the request automatically and generates links that increment offset by max for each step. The params attribute re-attaches your sort parameters to every link so sorting survives page navigation — a commonly missed detail that causes the sort order to silently reset on page two.
Step 3: Verify the behavior
Run the application (grails run-app from the project root, no special permissions needed) and check three things:
- Parameter effect: request
/book/index?max=5&offset=10and confirm exactly 5 records render, starting from the 11th record in sort order. - Generated links: view the page source and confirm each page link contains an
offsetthat is a multiple ofmax, and thatsort/orderappear in every link. - SQL: enable SQL logging (set
logSql: truein the dataSource config, or add a Hibernate SQL logger at debug level) and confirm the query includeslimitandoffsetclauses rather than fetching everything and trimming in memory.
Step 4: Check performance on the sorted column
Pagination with sorting makes the database order the full result set before applying the offset. On large tables this is the slow part. Ensure every property in your sort whitelist has a database index, or restrict sorting to indexed columns only. You can confirm index usage with your database's query plan tool (for example EXPLAIN on PostgreSQL or MySQL) against the logged SQL.
Limitations and recovery
- Deep offsets are inherently slow.
offset=500000still requires the database to scan and discard half a million rows. For very large datasets, keyset ("seek") pagination is the better design, but it is not whatlist()or<g:paginate>provide out of the box. - Count cost.
Book.count()is a separate query. If you add filters via criteria orwherequeries, the count must use the same filters or the page links will overstate the total. - Rollback: nothing here changes persistent state. Reverting is a code change only — restore the previous controller and view from version control and redeploy.
The complete pattern — clamped max, non-negative offset, whitelisted sort, matching count query, and <g:paginate> with sort parameters preserved — covers the common failure modes while staying entirely within standard GORM and GSP features.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.