The one-indexed-parameters Trap in Spring Boot: Why @PageableDefault Should Still Be Set to 0
Contents
- Introduction
- What Is the one-indexed-parameters Option?
- How It Works, Straight from the Source Code
- Parsing the Request Parameter: the parseAndApplyBoundaries Method
- Handling Defaults: the getPageable Method
- Processing @PageableDefault: the getDefaultPageRequestFrom Method
- Verifying with the Test Code
- The oneIndexedParametersDefaultsIndexOutOfRange Test
- Trying It Yourself
- Correct Usage, Summarized
- ✅ Correct configuration
- ❌ Incorrect configuration
- Summary Table
- Conclusion
- References
Introduction
Set the spring.data.web.pageable.one-indexed-parameters=true option and page numbers start at 1. So should the page value in @PageableDefault be set to 1 as well? The short answer is no. In this post we’ll dig into the Spring source code and figure out exactly why.
What Is the one-indexed-parameters Option?
Spring Data’s Pageable uses 0-based indexing by default: the first page is page=0. From the perspective of a frontend or an API client, though, page=1 being the first page is often more intuitive.
To address this, Spring Boot provides the following setting:
spring: data: web: pageable: one-indexed-parameters: trueWith this option enabled, a client requesting ?page=1 gets the first page back. Internally, Spring subtracts 1 from the request parameter to convert it to a 0-based index.
And this is where a common misconception creeps in. “I turned on 1-indexed mode, so I should set
@PageableDefault(page = 1)to make the first page the default, right?”
That reasoning is wrong. Let’s see why by looking at the Spring Data Commons source code.
How It Works, Straight from the Source Code
First, let’s look at the overall flow as a diagram.
flowchart TB
START["🌐 HTTP request<br/>GET /items?page=1&size=10"]
Q1{"page parameter<br/>present?"}
PARSE["parseAndApplyBoundaries()"]
Q2{"one-indexed-parameters<br/>= true?"}
SHIFT["page = page - 1<br/>(converts 1 → 0)"]
KEEP["page = page<br/>(no conversion)"]
DEFAULT["Use @PageableDefault or<br/>fallbackPageable"]
NOSHIFT["❌ Used as-is, no conversion<br/>@PageableDefault(page=0)"]
RESULT["✅ Pageable object created<br/>PageRequest.of(0, 10)"]
START --> Q1
Q1 -- Yes --> PARSE
Q1 -- No --> DEFAULT
PARSE --> Q2
Q2 -- Yes --> SHIFT
Q2 -- No --> KEEP
DEFAULT --> NOSHIFT
SHIFT --> RESULT
KEEP --> RESULT
NOSHIFT --> RESULT
style PARSE fill:#cfe8fb,color:#0f172a
style SHIFT fill:#c8e6c9,color:#0f172a
style NOSHIFT fill:#f8cdd5,color:#0f172a
style RESULT fill:#fbf3ab,color:#0f172a
As the diagram shows, the -1 conversion is applied only when the request parameter is present. Defaults supplied via @PageableDefault are used as-is, with no conversion.
Now let’s look at the actual code. The heart of it is the PageableHandlerMethodArgumentResolverSupport class, which is responsible for building the Pageable object from the HTTP request.
Parsing the Request Parameter: the parseAndApplyBoundaries Method
private Optional<Integer> parseAndApplyBoundaries(@Nullable String parameter, int upper, boolean shiftIndex) { if (!StringUtils.hasText(parameter)) { return Optional.empty(); // no parameter — return an empty Optional } try { int parsed = Integer.parseInt(parameter) - (oneIndexedParameters && shiftIndex ? 1 : 0); return Optional.of(parsed < 0 ? 0 : Math.min(parsed, upper)); } catch (NumberFormatException e) { return Optional.of(0); }}The core logic is this single line:
int parsed = Integer.parseInt(parameter) - (oneIndexedParameters && shiftIndex ? 1 : 0);It subtracts 1 from the parsed value only when oneIndexedParameters is true and shiftIndex is true. In other words, the -1 conversion is applied only while parsing the URL request parameter (?page=1).
Handling Defaults: the getPageable Method
protected Pageable getPageable(MethodParameter methodParameter, @Nullable String pageString, @Nullable String pageSizeString) {
Optional<Pageable> defaultOrFallback = getDefaultFromAnnotationOrFallback(methodParameter).toOptional(); Optional<Integer> page = parseAndApplyBoundaries(pageString, Integer.MAX_VALUE, true);
// ...
int p = page .orElseGet(() -> defaultOrFallback.map(Pageable::getPageNumber).orElseThrow(IllegalStateException::new));When the request parameter is absent, page is Optional.empty(). In that case the page number comes from defaultOrFallback, and the crucial point is that the value obtained via defaultOrFallback.map(Pageable::getPageNumber) is used as-is, with no conversion.
So where does defaultOrFallback come from? Inside getDefaultFromAnnotationOrFallback(methodParameter), Spring checks whether a @PageableDefault annotation is present, and if so calls getDefaultPageRequestFrom() to build the Pageable object.
Processing @PageableDefault: the getDefaultPageRequestFrom Method
private static Pageable getDefaultPageRequestFrom(MethodParameter parameter, MergedAnnotation<PageableDefault> defaults) {
int defaultPageNumber = defaults.getInt("page"); // annotation value taken verbatim int defaultPageSize = defaults.getInt("size");
// ...
return PageRequest.of(defaultPageNumber, defaultPageSize, ...); // used as-is, no conversion!}So with @PageableDefault(page = 1), defaultPageNumber becomes 1, and that value is passed to PageRequest.of(1, ...) verbatim, with no conversion. The result: the second page becomes your default.
Key takeaway: the
one-indexed-parametersoption applies only insideparseAndApplyBoundaries, only when parsing request parameters. It has no effect whatsoever on@PageableDefaultor thefallbackPageablesetting.
Verifying with the Test Code
The Spring Data Commons test code confirms this behavior as well.
The oneIndexedParametersDefaultsIndexOutOfRange Test
@Testvoid oneIndexedParametersDefaultsIndexOutOfRange() { var resolver = getResolver(); resolver.setOneIndexedParameters(true);
var request = new MockHttpServletRequest(); request.addParameter("page", "0"); // requesting page=0 in 1-indexed mode
var result = resolver.resolveArgument(supportedMethodParameter, null, new ServletWebRequest(request), null);
assertThat(result.getPageNumber()).isEqualTo(0); // result is 0 (first page)}This test verifies what happens when you request page=0 with one-indexed-parameters: true. The computed value is 0 - 1 = -1, but negatives are clamped to 0, so the first page is returned.
Trying It Yourself
You can test this directly with the following controller:
@RestControllerpublic class PageTestController {
@GetMapping("/test") public Map<String, Object> test( @PageableDefault(page = 0, size = 10) Pageable pageable) {
return Map.of( "pageNumber", pageable.getPageNumber(), "pageSize", pageable.getPageSize() ); }}With one-indexed-parameters: true configured:
| Request | Resulting pageNumber |
|---|---|
/test (no parameter) | 0 (first page) ✅ |
/test?page=1 | 0 (first page) ✅ |
/test?page=2 | 1 (second page) ✅ |
Had you set @PageableDefault(page = 1) instead, a request without any parameter would yield a pageNumber of 1 — making the second page the default.
Correct Usage, Summarized
✅ Correct configuration
@GetMapping("/items")public Page<Item> getItems( @PageableDefault(page = 0, size = 20) Pageable pageable) { return itemRepository.findAll(pageable);}❌ Incorrect configuration
@GetMapping("/items")public Page<Item> getItems( @PageableDefault(page = 1, size = 20) Pageable pageable) { // page=1 is the second page! return itemRepository.findAll(pageable);}Summary Table
| Where it’s set | Affected by one-indexed-parameters | Correct value |
|---|---|---|
URL parameter (?page=1) | ✅ Applied (-1 conversion) | 1 = first page |
@PageableDefault(page = X) | ❌ Not applied | 0 = first page |
fallbackPageable setting | ❌ Not applied | 0 = first page |
Watch out: the
getNumber()method on thePageobject also always returns a 0-based value. To respond to clients with a 1-based page number, you need to convert it yourself withpage.getNumber() + 1.
Conclusion
The one-indexed-parameters: true option affects only the interface with the client (the request parameters). Internally, Spring always operates on 0-based indexes, and @PageableDefault — as well as any PageRequest you construct directly in code — starts at 0 regardless of this option.
That this behavior is intentional is confirmed by Spring Data maintainer Oliver Drotbohm in a GitHub comment:
“Internally we always work with zero-indexed Pageable instances.”
The option was introduced as a convenience, but without a precise understanding of how it works internally, it can lead to unexpected bugs. Making a habit of checking the source code directly is the surest way to avoid traps like this one.