The one-indexed-parameters Trap in Spring Boot: Why @PageableDefault Should Still Be Set to 0

· updated 🌐 한국어로 보기
Contents

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: true

With 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-parameters option applies only inside parseAndApplyBoundaries, only when parsing request parameters. It has no effect whatsoever on @PageableDefault or the fallbackPageable setting.

Verifying with the Test Code

The Spring Data Commons test code confirms this behavior as well.

The oneIndexedParametersDefaultsIndexOutOfRange Test

@Test
void 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:

@RestController
public 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:

RequestResulting pageNumber
/test (no parameter)0 (first page) ✅
/test?page=10 (first page) ✅
/test?page=21 (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 setAffected by one-indexed-parametersCorrect value
URL parameter (?page=1)✅ Applied (-1 conversion)1 = first page
@PageableDefault(page = X)❌ Not applied0 = first page
fallbackPageable setting❌ Not applied0 = first page

Watch out: the getNumber() method on the Page object also always returns a 0-based value. To respond to clients with a 1-based page number, you need to convert it yourself with page.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.

References