Spring Boot Pagination: Pageable, PagedModel & Custom Strategies
Master Spring Boot REST API pagination with Pageable, PagedModel, and custom strategies.
20+ years shipping production Java in banking & fintech. Written from production experience, not tutorials.
- ✓Java 17 or later
- ✓Spring Boot 3.x project with Spring Data JPA and a database
- ✓Basic understanding of REST APIs and Spring MVC
- ✓Familiarity with Spring Data JPA repositories
- Use Spring Data
Pageablefor JPA-backed pagination. It handles offset/limit automatically. - Return
PagedModelfrom Spring HATEOAS for self-describing REST responses with links. - For custom performance, implement offset or keyset pagination; avoid
OFFSETon large datasets. - Always cap page size server-side to prevent abuse.
- Test pagination edge cases: empty pages, large offsets, and concurrent modifications.
Think of pagination like browsing a photo album with 10,000 pictures. Instead of flipping through every single photo, you ask for page 5, which shows photos 41โ50. The album tells you there are 1,000 pages total, and gives you a bookmark (link) to the next page. That's exactly what Spring Boot pagination does for your API data.
If you're building a REST API that serves any meaningful amount of data, you need pagination. Without it, you'll eventually serve millions of rows to a mobile client, crash your database, and get a 3 AM support call. I've seen it happen.
Spring Boot, with Spring Data JPA and Spring HATEOAS, gives you a solid foundation for pagination out of the box. But here's the hard truth: most teams get pagination wrong. They either return everything (bad), implement naive offset pagination on huge datasets (also bad), or forget to secure it (worst).
In this article, I'll show you how to implement pagination correctly using Pageable, PagedModel, and custom strategies like keyset pagination. We'll cover what works in production, what doesn't, and the gotchas the official docs gloss over. By the end, you'll be able to build a paginated API that scales from day one to millions of records.
Setting Up Spring Data Pagination
The foundation of pagination in Spring Boot is Spring Data's Pageable interface. Combine it with a JpaRepository and you get pagination for free. Here's how to set it up.
First, define your repository:
``java public interface TransactionRepository extends JpaRepository<Transaction, Long> { Page<Transaction> findByAccountId(Long accountId, Pageable pageable); } ``
The findByAccountId method automatically receives a Pageable parameter. Spring Data generates the SQL with LIMIT and OFFSET clauses. Under the hood, it also executes a count query to populate Page.getTotalElements().
In your controller, you can accept Pageable directly:
``java @GetMapping("/accounts/{accountId}/transactions") public Page<Transaction> getTransactions(@PathVariable Long accountId, Pageable pageable) { return transactionRepository.findByAccountId(accountId, pageable); } ``
Spring Boot auto-configures Pageable from query parameters: page, size, and sort. For example: GET /accounts/1/transactions?page=0&size=20&sort=createdAt,desc.
Important: By default, page index is 0-based. If your frontend expects 1-based pages, set spring.data.web.pageable.one-indexed-parameters=true in application.properties. And always, always set a max page size to prevent abuse: spring.data.web.pageable.max-page-size=100.
size=1000000 and brought down the database. The fix was simple: spring.data.web.pageable.max-page-size=100. Set it today.Pageable gives you automatic pagination with minimal code, but always configure max page size and be aware of the zero-index default.Returning PagedModel with Spring HATEOAS
Returning raw Page objects works, but it's not RESTful. The response includes metadata like pageable and sort, which are implementation details. A better approach is to use PagedModel from Spring HATEOAS, which wraps your content with navigational links (first, last, next, prev).
Add the HATEOAS starter:
``xml <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-hateoas</artifactId> </dependency> ``
Then inject PagedResourcesAssembler into your controller:
```java @GetMapping("/accounts/{accountId}/transactions") public PagedModel<EntityModel<Transaction>> getTransactions( @PathVariable Long accountId, Pageable pageable, PagedResourcesAssembler<Transaction> assembler) {
Page<Transaction> page = transactionRepository.findByAccountId(accountId, pageable); return assembler.toModel(page); } ```
The response now includes _links with self, first, next, prev, and last URLs. This makes your API self-describing and easier to navigate without hardcoding URLs.
Customizing links: You can also add custom links per entity using RepresentationModelAssembler. But for most cases, the default PagedResourcesAssembler works perfectly.
Gotcha: The PagedResourcesAssembler uses the current request's URI to build links. If you're behind a reverse proxy, you may need to configure ForwardedHeaderFilter or set server.forward-headers-strategy=framework to get correct URLs.
PagedResourcesAssembler to produce self-describing paginated responses with navigational links โ it's more RESTful and easier to consume.Custom Pagination Strategies: Keyset Pagination
Offset pagination (LIMIT 20 OFFSET 10000) works for small datasets, but as your data grows, it becomes a performance nightmare. The database still scans all rows before the offset. For large datasets, you need keyset pagination (also called seek method).
Keyset pagination uses a WHERE clause to filter rows after a known cursor. For example, if your last item has id = 1000 and createdAt = '2025-01-01', the next query is:
``sql SELECT * FROM transactions WHERE (created_at, id) > ('2025-01-01', 1000) ORDER BY created_at, id LIMIT 20 ``
This query can use a composite index on (created_at, id) and avoids scanning rows before the cursor. It's O(log n) instead of O(n).
In Spring Data JPA, you can implement this with a custom query:
``java @Query("SELECT t FROM Transaction t WHERE " + "(t.createdAt, t.id) > (:lastCreatedAt, :lastId) " + "ORDER BY t.createdAt, t.id") List<Transaction> findNextPage(@Param("lastCreatedAt") LocalDateTime lastCreatedAt, @Param("lastId") Long lastId, Pageable pageable); ``
But Spring Data JPA doesn't support tuple comparison in WHERE clauses natively. You'll need to use a native query or rewrite it as:
``java @Query(value = "SELECT * FROM transactions WHERE " + "(created_at > :lastCreatedAt) OR " + "(created_at = :lastCreatedAt AND id > :lastId) " + "ORDER BY created_at, id LIMIT :size", nativeQuery = true) List<Transaction> findNextPageNative(@Param("lastCreatedAt") LocalDateTime lastCreatedAt, @Param("lastId") Long lastId, @Param("size") int size); ``
Trade-offs: Keyset pagination doesn't support arbitrary page numbers (you can't jump to page 500). It's ideal for infinite scroll or 'load more' patterns. Also, you lose the total count unless you execute a separate count query.
When to use: Use keyset pagination for large, append-only datasets like logs, transactions, or activity feeds. For smaller datasets or when users need to jump to specific pages, stick with offset.
What the Official Docs Won't Tell You
The official Spring Data documentation covers the basics, but it leaves out several production realities. Here are the gotchas I've encountered:
1. Count queries can kill performance. Every Page query executes a count query. For complex joins, this can be as expensive as the data query. If you don't need the exact total (e.g., infinite scroll), consider returning a Slice instead of Page. Slice knows if there's a next page but doesn't count total elements.
``java Slice<Transaction> findByAccountId(Long accountId, Pageable pageable); ``
2. Sort injection is a security risk. Spring Data's Pageable accepts sort parameters like sort=name,asc. Without validation, a malicious user could sort by a sensitive column or cause a SQL error. Use a whitelist of allowed sort fields:
``java public Pageable validateSort(Pageable pageable, Set<String> allowedFields) { Sort sort = pageable.getSort(); for (Sort.Order order : sort) { if (!allowedFields.contains(order.getProperty())) { throw new IllegalArgumentException("Invalid sort field: " + order.getProperty()); } } return pageable; } ``
3. PagedResourcesAssembler has a hidden dependency on the current request. If you're using reactive stacks or async operations, the request context may not be available. You'll need to manually pass the URI.
4. Large size values can cause out-of-memory errors. Even with max-page-size, a client could request size=10000 if you set the limit too high. Monitor your memory usage and consider setting a lower default.
5. Keyset pagination with mutable data can skip or duplicate rows. If a new row is inserted before the cursor, it might be missed. Use a stable sort (like including id as a tiebreaker) and consider using a timestamp that doesn't change.
sort=password on a user endpoint. The attacker sorted by password hash and easily identified weak passwords. Don't be that team.Custom Pagination Strategies: Cursor-Based with UUIDs
Another approach to keyset pagination is using opaque cursors. Instead of exposing database columns, you encode the cursor as a token (e.g., base64-encoded JSON or a UUID). This is common in GraphQL and modern REST APIs.
Here's a simple implementation:
``java public class CursorPage<T> { private List<T> content; private String nextCursor; private boolean hasNext; // getters/setters } ``
In your service, you generate the cursor from the last item's ID:
``java public CursorPage<Transaction> getTransactions(Long accountId, String cursor, int size) { Pageable pageable = PageRequest.of(0, size + 1); // fetch one extra to check hasNext List<Transaction> transactions; if (cursor == null) { transactions = transactionRepository.findByAccountIdOrderById(accountId, pageable); } else { Long decodedId = decodeCursor(cursor); transactions = transactionRepository.findByIdGreaterThanAndAccountId(decodedId, accountId, pageable); } boolean hasNext = ``transactions.size() > size; if (hasNext) { transactions = transactions.subList(0, size); } String nextCursor = hasNext ? encodeCursor(transactions.get(transactions.size()-1).getId()) : null; return new CursorPage<>(transactions, nextCursor, hasNext); }
Pros: Cursors are opaque โ clients can't manipulate them to guess data. They also handle data consistency better because they don't depend on offsets.
Cons: You lose the ability to jump to arbitrary pages. Also, you need to encode/decode cursors securely (e.g., base64 with HMAC signing).
When to use: Public APIs where you don't want to expose internal IDs, or when you need stable pagination despite insertions.
Testing Pagination Edge Cases
Pagination bugs often surface in edge cases. Here's a checklist of scenarios to test:
- Empty result set: Ensure the API returns an empty page with
totalElements: 0and no next link. - Single page: When total elements <= page size, there should be no next link.
- Last page: Requesting the last page should return
hasNext: falseand nonextlink. - Page beyond total: What happens if client requests page 1000 when there are only 10 pages? Spring Data returns an empty page by default, but you may want to return 404.
- Invalid sort fields: Should return 400 Bad Request, not 500.
- Concurrent modifications: Insertions/deletions during pagination can cause inconsistent results. Test with concurrent load.
- Large page size: Verify that
max-page-sizeis enforced and returns 400 if exceeded.
Use Spring Boot's @WebMvcTest to unit test your controller:
```java @WebMvcTest(TransactionController.class) class TransactionControllerTest {
@Autowired private MockMvc mockMvc;
@MockBean private TransactionRepository repository;
@Test void shouldReturnPagedModel() throws Exception { Page<Transaction> page = new PageImpl<>(List.of(new Transaction()), PageRequest.of(0, 20), 100); given(repository.findByAccountId(anyLong(), any())).willReturn(page);
mockMvc.perform(get("/accounts/1/transactions?page=0&size=20")) .andExpect(status().isOk()) .andExpect(jsonPath("$._links.self").exists()); } } ```
Integration tests with @SpringBootTest and an embedded database can catch JPA query issues.
size > 0 in a @ControllerAdvice.@WebMvcTest for controller tests and integration tests for full stack.The 3 AM Offset Meltdown
OFFSET 100000 LIMIT 20 on a table with 2 million rows. PostgreSQL scanned all rows before the offset, causing massive I/O and eventually a connection pool exhaustion.(created_at, id). The query became WHERE (created_at, id) > (?, ?) ORDER BY created_at, id LIMIT 20.- Offset pagination is O(n) for large offsets โ avoid it for deep pages.
- Always benchmark pagination queries on realistic data volumes.
- Provide a maximum page size (e.g., 100) via server-side configuration.
- Consider keyset pagination for real-time, infinite-scroll UIs.
- Monitor slow queries with Spring Boot Actuator and a tool like pg_stat_statements.
OFFSET queries. Consider switching to keyset pagination if deep pages are common.spring.data.web.pageable.max-page-size in application properties.PagedResourcesAssembler correctly.curl -X GET 'http://localhost:8080/api/items?page=0&size=20'curl -X GET 'http://localhost:8080/api/items?page=1&size=20'spring.data.web.pageable.one-indexed-parameters=true if your frontend expects 1-based pages.| File | Command / Code | Purpose |
|---|---|---|
| TransactionController.java | @GetMapping("/accounts/{accountId}/transactions") | Setting Up Spring Data Pagination |
| TransactionControllerWithHateoas.java | @GetMapping("/accounts/{accountId}/transactions") | Returning PagedModel with Spring HATEOAS |
| TransactionRepositoryKeyset.java | public interface TransactionRepository extends JpaRepository | Custom Pagination Strategies |
| SortValidation.java | @GetMapping("/accounts/{accountId}/transactions") | What the Official Docs Won't Tell You |
| CursorPaginationService.java | public CursorPage | Custom Pagination Strategies |
| PaginationTest.java | @WebMvcTest(TransactionController.class) | Testing Pagination Edge Cases |
Key takeaways
Pageable for quick offset pagination, but always configure max page size and validate sort fields.PagedModel from Spring HATEOAS for self-describing, RESTful responses with navigational links.Interview Questions on This Topic
What is the difference between `Page` and `Slice` in Spring Data?
Page extends Slice and adds a count query to get total elements and total pages. Slice only knows if there's a next page by checking if the query returned more results than the page size. Use Slice when you don't need the exact total count for performance reasons.Frequently Asked Questions
20+ years shipping production Java in banking & fintech. Written from production experience, not tutorials.
That's Spring Boot. Mark it forged?
5 min read · try the examples if you haven't