Spring Boot Pagination and Sorting, and the Page You Should Not Return
Accept a Pageable parameter and Spring Boot reads page, size and sort from the query string — the default is page 0, size 20, unsorted. Do not return the Page itself: Spring Data logs a warning that PageImpl's JSON has no stability guarantee, and PagedModel gives you a documented two-key shape instead. Also: every Page costs a second SQL statement, a COUNT over the whole table.
Paginating a Spring Boot endpoint takes two lines, and the two-line version has a problem your application is already logging about. This article does the easy part quickly, then the three things worth knowing: the warning, the count query, and what sorting actually exposes.
Everything below was run against a Spring Boot 4.1.1 application with fifty rows in H2, and the JSON shown is what came back.
The two-line version
@GetMapping
public Page<Product> all(Pageable pageable) {
return repo.findAll(pageable);
}
That is the whole thing. Pageable as a parameter is resolved from the query string with no configuration — page, size and sort all work immediately.
With no parameters at all:
content: 20 items | totalElements: 50 | totalPages: 3 | number: 0 | size: 20
The default is page 0, size 20, unsorted. Worth knowing because it is the page every caller gets when they forget to ask.
And with parameters:
curl "localhost:8080/api/products?page=1&size=3&sort=price,desc"
number: 1 size: 3 prices: [470, 460, 450]
Second page, three per page, most expensive first. Nothing to write.
The warning your application is already logging
Call that endpoint and look at the log:
WARN PageModule$WarningLoggingModifier : Serializing PageImpl instances as-is is not supported,
meaning that there is no guarantee about the stability of the resulting JSON structure!
Spring Data is telling you that the JSON it just produced is not a contract it will keep. Look at what a Page actually serialises to — eleven top-level keys:
content, empty, first, last, number, numberOfElements,
pageable, size, sort, totalElements, totalPages
...where pageable is itself a nested object exposing offset, pageNumber, pageSize, paged and unpaged. That is a class's internal state on the wire. A client that reads pageable.offset has coupled itself to Spring Data's implementation, and a future version changing it would break that client.
Common mistake
Returning
Page<T>from a controller and shipping it. It works, the JSON looks reasonable, and you have published a shape nobody promises to maintain — to clients you may not control. This is the most common version of this code on the internet, including in the first draft of the app used for this article.
PagedModel is the shape to return
One wrapper:
@GetMapping("/paged")
public PagedModel<Product> paged(Pageable pageable) {
return new PagedModel<>(repo.findAll(pageable));
}
And the response drops from eleven keys to two:
{
"content": [ ... ],
"page": { "size": 3, "number": 0, "totalElements": 50, "totalPages": 17 }
}
PagedModel exists for exactly this, and the difference is measurable rather than stylistic. On the same running application:
warnings after a PagedModel call: 0
warnings after a raw Page call: 1
Same query, same data, same pagination. One of them is a documented response shape and the other is a warning in your logs.
A DTO is the other half of the answer
PagedModel fixes the envelope. It does not fix what is inside it — the example above is still serialising Product entities, which publishes your database schema as your API.
Page has a map, so the conversion is one line and happens before the wrapper:
record ProductView(Long id, String name, int price) {}
@GetMapping("/paged")
public PagedModel<ProductView> paged(Pageable pageable) {
return new PagedModel<>(repo.findAll(pageable)
.map(p -> new ProductView(p.getId(), p.getName(), p.getPrice())));
}
category is now not in the response because the record does not have it, which is the point — the API says what it means to say. This is the same argument as in any other REST endpoint; pagination does not change it.
Every Page costs a COUNT
Turn on spring.jpa.show-sql=true and make one request. Two statements come out:
select p1_0.id, p1_0.category, p1_0.name, p1_0.price
from product p1_0 offset ? rows fetch first ? rows only;
select count(p1_0.id) from product p1_0;
The second one is not a mistake. Page promises totalElements and totalPages, and there is no way to know either without counting the matching rows. You asked for twenty products and the database also counted all fifty.
On fifty rows that is free. On a table with ten million rows and a WHERE clause that is not fully indexed, the count is the slow half of the request — and it runs on every page, including page 400, where nobody is reading the total anyway.
Slice, when you do not need the total
Slice<Product> findByPriceGreaterThan(int price, Pageable pageable);
The same shape of request against that method logs one statement:
select p1_0.id, p1_0.category, p1_0.name, p1_0.price
from product p1_0 where p1_0.price > ? fetch first ? rows only;
No count. And the JSON has nine keys instead of eleven — Slice has neither totalElements nor totalPages, because it does not know them and does not pretend to.
What it does know is whether there is a next one, which is all an infinite scroll or a "load more" button needs. If your UI shows page numbers you need Page; if it shows "next", Slice halves your query count.
Tip
This is the cheapest performance decision in the whole article and it is made by changing a return type. Look at what the client actually renders before defaulting to
Page.
Sorting is an API decision, not a feature
sort=price,desc works because price is a property of the entity. So does sort=category, and so would sort= anything else the entity exposes — including fields with no index behind them, and fields you would rather not let a caller order a million rows by.
That is worth deciding deliberately rather than inheriting. Whitelisting the sortable properties, or accepting a small enum instead of a raw Sort, turns an accidental capability into a designed one.
And the same caution applies to depth. The generated SQL says offset ? rows — which means the database is skipping rows it has already found, and skipping 100,000 of them costs more than skipping none. For deep pagination the fix is keyset pagination: where id > :lastSeenId order by id limit 20, which stays flat because there is nothing to skip. You lose the ability to jump to page 400, and you were unlikely to be serving real users there.
If you are starting from an empty project, creating one and adding data-jpa gets you everything above; the database behind it is a separate decision and does not change any of this code.
Frequently asked questions
- What is Spring Boot's default page size?
- Twenty, with page number 0 and no sorting. Measured against fifty seeded rows, a request with no parameters returned twenty items and reported totalElements 50 and totalPages 3. You can change it with spring.data.web.pageable.default-page-size.
- Why does Spring Data warn about serializing PageImpl?
- Because the JSON that Page produces is not a contract it promises to keep — it exposes internals like the nested pageable object, and a future version could change them. The warning appears on every response that serialises a Page directly. Wrap the result in PagedModel and it stops.
- What is the difference between Page and Slice?
- A count query. Page knows totalElements and totalPages, which requires a second SQL statement counting the whole matching set; Slice does not and issues only one. Measured, the Page request logged a select and a count, the Slice request logged only the select.
- Should I return entities from a paginated endpoint?
- No, for the same reason you should not return them from any endpoint — it publishes your database schema as your API. Page has a map method, so converting to a record is one line and costs nothing structurally.
- Does offset pagination scale?
- Not indefinitely. The SQL Spring Data generates uses OFFSET, and a database serving offset 100,000 still has to work through the rows it is skipping. For deep pagination over large tables, keyset pagination — "everything after this id" — keeps the cost flat, at the price of losing random page access.