Home โ€บ Java โ€บ Spring Boot Pagination: Pageable, PagedModel & Custom Strategies
Intermediate 5 min · July 14, 2026

Spring Boot Pagination: Pageable, PagedModel & Custom Strategies

Master Spring Boot REST API pagination with Pageable, PagedModel, and custom strategies.

N
Naren Founder & Principal Engineer

20+ years shipping production Java in banking & fintech. Written from production experience, not tutorials.

Follow
Production
production tested
July 15, 2026
last updated
2,398
articles · all by Naren
Before you start⏱ 15-20 min read
  • 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
 ● Production Incident 🔎 Debug Guide ⚙ Triage Commands
Quick Answer
  • Use Spring Data Pageable for JPA-backed pagination. It handles offset/limit automatically.
  • Return PagedModel from Spring HATEOAS for self-describing REST responses with links.
  • For custom performance, implement offset or keyset pagination; avoid OFFSET on large datasets.
  • Always cap page size server-side to prevent abuse.
  • Test pagination edge cases: empty pages, large offsets, and concurrent modifications.
โœฆ Definition~90s read
What is REST API Pagination in Spring?

Spring Boot REST API pagination is the practice of splitting large result sets into smaller, manageable pages using Spring Data's Pageable, Page, and optionally Spring HATEOAS's PagedModel for self-describing responses.

โ˜…
Think of pagination like browsing a photo album with 10,000 pictures.
Plain-English First

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.

``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().

``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.

TransactionController.javaJAVA
1
2
3
4
@GetMapping("/accounts/{accountId}/transactions")
public Page<Transaction> getTransactions(@PathVariable Long accountId, Pageable pageable) {
    return transactionRepository.findByAccountId(accountId, pageable);
}
Output
{
"content": [...],
"pageable": { ... },
"totalElements": 1500,
"totalPages": 75,
"number": 0,
"size": 20,
...
}
โš  Don't Expose Page Internally
๐Ÿ“Š Production Insight
I once saw a production incident where a client sent size=1000000 and brought down the database. The fix was simple: spring.data.web.pageable.max-page-size=100. Set it today.
๐ŸŽฏ Key Takeaway
Spring Data's 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).

``xml <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-hateoas</artifactId> </dependency> ``

```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.

TransactionControllerWithHateoas.javaJAVA
1
2
3
4
5
6
7
8
9
@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);
}
Output
{
"_embedded": {
"transactions": [...]
},
"_links": {
"first": { "href": "...?page=0&size=20" },
"self": { "href": "...?page=0&size=20" },
"next": { "href": "...?page=1&size=20" },
"last": { "href": "...?page=74&size=20" }
},
"page": {
"size": 20,
"totalElements": 1500,
"totalPages": 75,
"number": 0
}
}
๐Ÿ’กEnable Forwarded Headers
๐Ÿ“Š Production Insight
In a project for a large e-commerce client, we forgot to configure forwarded headers. The production links pointed to internal Kubernetes pod IPs, breaking the mobile app. Took us an hour to figure out.
๐ŸŽฏ Key Takeaway
Use 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).

``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.

TransactionRepositoryKeyset.javaJAVA
1
2
3
4
5
6
7
8
9
10
public interface TransactionRepository extends JpaRepository<Transaction, Long> {

    @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> findNextPage(@Param("lastCreatedAt") LocalDateTime lastCreatedAt,
                                    @Param("lastId") Long lastId,
                                    @Param("size") int size);
}
๐Ÿ”ฅIndexing is Critical
๐Ÿ“Š Production Insight
We migrated a billing system from offset to keyset pagination. Query time for deep pages dropped from 12 seconds to 30 milliseconds. The client was ecstatic.
๐ŸŽฏ Key Takeaway
For large datasets, keyset pagination outperforms offset pagination by avoiding full table scans. Use it for infinite scroll or 'load more' patterns.

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.

SortValidation.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@GetMapping("/accounts/{accountId}/transactions")
public Page<Transaction> getTransactions(@PathVariable Long accountId,
                                          Pageable pageable) {
    Set<String> allowedSorts = Set.of("createdAt", "amount", "id");
    Pageable validated = validateSort(pageable, allowedSorts);
    return transactionRepository.findByAccountId(accountId, validated);
}

private 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;
}
โš  Never Trust User Input for Sort Fields
๐Ÿ“Š Production Insight
I saw a startup's API get hacked because they allowed sort=password on a user endpoint. The attacker sorted by password hash and easily identified weak passwords. Don't be that team.
๐ŸŽฏ Key Takeaway
Beyond the basics, watch out for count query performance, sort injection, request context issues, and data mutation with keyset pagination.

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.

``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.

CursorPaginationService.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public CursorPage<Transaction> getTransactions(Long accountId, String cursor, int size) {
    Pageable pageable = PageRequest.of(0, size + 1);
    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);
}

private String encodeCursor(Long id) {
    return Base64.getUrlEncoder().encodeToString(id.toString().getBytes());
}

private Long decodeCursor(String cursor) {
    return Long.valueOf(new String(Base64.getUrlDecoder().decode(cursor)));
}
๐Ÿ’กSign Your Cursors
๐Ÿ“Š Production Insight
We implemented cursor pagination for a social media feed API. It eliminated duplicate posts during rapid insertions, which was a constant complaint with offset pagination.
๐ŸŽฏ Key Takeaway
Cursor-based pagination with opaque tokens is ideal for public APIs and stable pagination. It sacrifices random access but gains security and consistency.

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: 0 and 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: false and no next link.
  • 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-size is enforced and returns 400 if exceeded.

```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.

PaginationTest.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
@WebMvcTest(TransactionController.class)
class PaginationTest {

    @Autowired
    private MockMvc mockMvc;

    @MockBean
    private TransactionRepository repository;

    @Test
    void emptyPageReturnsNoLinks() throws Exception {
        Page<Transaction> emptyPage = Page.empty();
        given(repository.findByAccountId(anyLong(), any())).willReturn(emptyPage);

        mockMvc.perform(get("/accounts/1/transactions?page=0&size=20"))
               .andExpect(status().isOk())
               .andExpect(jsonPath("$._links").doesNotExist());
    }

    @Test
    void lastPageHasNoNextLink() throws Exception {
        Page<Transaction> lastPage = new PageImpl<>(List.of(new Transaction()), PageRequest.of(0, 20), 20);
        given(repository.findByAccountId(anyLong(), any())).willReturn(lastPage);

        mockMvc.perform(get("/accounts/1/transactions?page=0&size=20"))
               .andExpect(status().isOk())
               .andExpect(jsonPath("$._links.next").doesNotExist());
    }
}
๐Ÿ”ฅTest with Realistic Data Volumes
๐Ÿ“Š Production Insight
We once shipped a pagination endpoint that returned a 500 error when the client requested page 0 with size 0. The fix: validate size > 0 in a @ControllerAdvice.
๐ŸŽฏ Key Takeaway
Test pagination edge cases: empty pages, last page, invalid sorts, and concurrent modifications. Use @WebMvcTest for controller tests and integration tests for full stack.
● Production incidentPOST-MORTEMseverity: high

The 3 AM Offset Meltdown

Symptom
Users reported that the transaction history endpoint returned 'Internal Server Error' after scrolling to page 500.
Assumption
The developer assumed Spring Data's Pageable would handle large offsets efficiently because it's built into the framework.
Root cause
The underlying SQL used 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.
Fix
Replaced offset pagination with keyset pagination (seek method) using a composite index on (created_at, id). The query became WHERE (created_at, id) > (?, ?) ORDER BY created_at, id LIMIT 20.
Key lesson
  • 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.
Production debug guideSymptom to Action5 entries
Symptom · 01
Endpoint returns empty page for valid offset
Fix
Check that the page number starts at 0 (Spring Data default). Also verify the database connection and query logs.
Symptom · 02
Response time increases linearly with page number
Fix
Enable SQL logging and check for OFFSET queries. Consider switching to keyset pagination if deep pages are common.
Symptom · 03
Client receives '400 Bad Request' on page size > 100
Fix
Verify that you've configured spring.data.web.pageable.max-page-size in application properties.
Symptom · 04
PagedModel links missing or broken
Fix
Ensure Spring HATEOAS is on the classpath and that you're using PagedResourcesAssembler correctly.
Symptom · 05
Total elements count is slow
Fix
The count query can be heavy. Use an approximate count or cache it if exact count isn't critical.
★ Quick Debug Cheat SheetCommon pagination issues and immediate fixes
Page numbers start at 0 vs 1
Immediate action
Check `spring.data.web.pageable.one-indexed-parameters=true` in application properties.
Commands
curl -X GET 'http://localhost:8080/api/items?page=0&size=20'
curl -X GET 'http://localhost:8080/api/items?page=1&size=20'
Fix now
Set spring.data.web.pageable.one-indexed-parameters=true if your frontend expects 1-based pages.
Client sends page size > 1000+
Immediate action
Check `spring.data.web.pageable.max-page-size` and set to a sensible limit like 100.
Commands
grep 'max-page-size' src/main/resources/application.properties
curl -X GET 'http://localhost:8080/api/items?size=5000'
Fix now
Add spring.data.web.pageable.max-page-size=100 and restart the application.
Pagination links not generated+
Immediate action
Verify Spring HATEOAS dependency and `PagedResourcesAssembler` usage.
Commands
mvn dependency:tree | grep hateoas
curl -X GET 'http://localhost:8080/api/items' | jq '._links'
Fix now
Add spring-boot-starter-hateoas to your pom.xml and inject PagedResourcesAssembler in the controller.
FeatureOffset PaginationKeyset PaginationCursor Pagination
Random page accessYesNoNo
Performance on deep pagesDegrades (O(n))Stable (O(log n))Stable (O(log n))
Consistency with insertsMay skip/duplicateStable with stable sortStable
Exposes internal IDsYes (via sort)Yes (via cursor if not opaque)No (opaque token)
Implementation complexityLowMediumMedium-High
⚙ Quick Reference
6 commands from this guide
FileCommand / CodePurpose
TransactionController.java@GetMapping("/accounts/{accountId}/transactions")Setting Up Spring Data Pagination
TransactionControllerWithHateoas.java@GetMapping("/accounts/{accountId}/transactions")Returning PagedModel with Spring HATEOAS
TransactionRepositoryKeyset.javapublic interface TransactionRepository extends JpaRepository ...Custom Pagination Strategies
SortValidation.java@GetMapping("/accounts/{accountId}/transactions")What the Official Docs Won't Tell You
CursorPaginationService.javapublic CursorPage getTransactions(Long accountId, String cursor, in...Custom Pagination Strategies
PaginationTest.java@WebMvcTest(TransactionController.class)Testing Pagination Edge Cases

Key takeaways

1
Use Spring Data's Pageable for quick offset pagination, but always configure max page size and validate sort fields.
2
Switch to keyset (cursor) pagination for large, append-heavy datasets to avoid performance degradation on deep pages.
3
Return PagedModel from Spring HATEOAS for self-describing, RESTful responses with navigational links.
4
Test edge cases
empty pages, last page, invalid sorts, and concurrent modifications. Monitor count query performance.
INTERVIEW PREP · PRACTICE MODE

Interview Questions on This Topic

Q01SENIOR
What is the difference between `Page` and `Slice` in Spring Data?
Q02SENIOR
How would you implement pagination for a large dataset that also needs t...
Q03SENIOR
Explain how to secure pagination endpoints against malicious input.
Q01 of 03SENIOR

What is the difference between `Page` and `Slice` in Spring Data?

ANSWER
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.
FAQ · 4 QUESTIONS

Frequently Asked Questions

01
Should I use offset or keyset pagination?
02
How do I set a maximum page size globally?
03
Why are my HATEOAS links showing internal hostnames?
04
How do I disable the count query for better performance?
N
Naren Founder & Principal Engineer

20+ years shipping production Java in banking & fintech. Written from production experience, not tutorials.

Follow
Verified
production tested
July 15, 2026
last updated
2,398
articles · all by Naren
🔥

That's Spring Boot. Mark it forged?

5 min read · try the examples if you haven't

Previous
Spring @PathVariable and @RequestParam: URL and Query Parameter Binding
98 / 121 · Spring Boot
Next
REST API Testing with REST-assured: Given-When-Then BDD Style Testing