RadhaPublication
HomeInterviewLearning PathsRole TracksMockBooksPricing
Login / Sign UpLog in

© 2026 Radha Publication. All rights reserved.

Shop on AmazonDonateContactPrivacyTerms
Spring Boot questions
MediumSpring Boot#378 min readJul 11, 2026

Pagination in Spring Boot.

practice
learning
Practice modeTest yourself instead of reading straight through

Interviewers love pagination because it quietly tests REST design, database thinking, and API ergonomics all at once.

Question: What is pagination in Spring Boot, and how do you use it in a REST API?

Answer: Pagination means returning a large result set in smaller chunks instead of sending everything at once. In Spring Boot, you usually do this with Spring Data’s Pageable and Page<T>, which let the client ask for a page number, a page size, and optional sorting. This keeps responses faster, reduces memory use, and makes APIs easier to consume.

Interview-Ready Answer: “In Spring Boot, I paginate REST APIs by accepting a Pageable in the controller and returning a Page<T> or a DTO built from it. The client sends page, size, and optionally sort; Spring Data converts that into database pagination, usually with limit and offset. I like this approach because it keeps payloads small and also gives metadata like total pages. For very large datasets, I may switch to Slice or keyset pagination to avoid expensive count queries.”

🧠 Memory Map
Memory map — visual summary of this topic

What pagination really means

Pagination is just controlled slicing of a result set. Instead of returning 50,000 rows, the API returns 20, 50, or 100 rows at a time. In Spring Boot, the common building blocks are:

  • Pageable — the request object that says which slice you want.
  • Page<T> — the response object with content plus totals.
  • Slice<T> — like a page, but without the total count.

By default, Spring Data uses zero-based page numbers, so the first page is page=0. The default page size is typically 20. You can change these globally with properties like spring.data.web.pageable.default-page-size and spring.data.web.pageable.one-indexed-parameters=true, or per endpoint with @PageableDefault.

How it works under the hood

  1. The client calls something like /api/products?page=0&size=10&sort=name,asc.
  2. Spring MVC sees a controller parameter of type Pageable and uses a resolver called PageableHandlerMethodArgumentResolver to build the request object.
  3. The repository receives that Pageable and translates it into database pagination, usually LIMIT plus OFFSET with an ORDER BY clause.
  4. If you return Page<T>, Spring Data usually runs a second query to count the total rows. That is what lets it answer questions like “how many pages exist?”
  5. Spring serializes the page content and metadata back to JSON so the frontend can render page controls or a “load more” button.

Memory idea: think of pagination like a library shelf. sort decides the order of the books, page chooses the shelf number, and size tells you how many books to carry out.

When and why to use it

  • Use pagination when the list is large, user-facing, or expensive to fetch.
  • Use it when the UI shows tables, search results, product catalogs, audit logs, or admin dashboards.
  • Avoid returning raw lists when the dataset can grow without bound; it hurts latency, memory, and network size.

It also improves user experience: a page of 20 items loads quickly, and the client can request the next chunk only if needed.

Page vs Slice vs Cursor

TypeGives youCount queryBest for
Page<T>Items + totalsYesClassic paged UI
Slice<T>Items + next/prevNoInfinite scroll
CursorNext tokenNoHuge feeds

Page is the most interview-friendly choice because it gives totalElements and totalPages. Slice is lighter because it avoids the count query; Spring only needs one extra row to know whether another slice exists. Cursor/keyset pagination is best for very deep or rapidly changing datasets, because offset-based paging can become slow and unstable.

Performance and complexity notes

  • Database cost: with offset pagination, many engines do work proportional to offset + size. A request like page 10,000 with size 20 may force the database to skip about 200,000 rows.
  • Count query cost: Page<T> often means two queries: one for data and one for count. On large joins, the count can become the slow part.
  • Memory cost: on the app side, the response is only the page content, so memory stays closer to O(size) instead of O(total rows).
  • Sorting matters: always use a stable sort, usually a unique column like id or a timestamp plus id. Without that, page 2 may repeat or skip rows when new records arrive.

Version note: the core behavior is the same in Spring Boot 2 and 3. The big Boot 3 change is the move from javax to jakarta packages for JPA and validation, not pagination itself.

Important edge cases

  1. Negative page or zero size should be rejected with 400 Bad Request.
  2. Very large size should be capped, for example at 50, 100, or 200, to prevent abuse.
  3. Page past the end usually returns an empty content list, which is normal and not an error.
  4. Unstable sort causes duplicate or missing items between requests when data changes often.
  5. Serialization should often use DTOs, not entities, to avoid exposing internal fields or lazy-loading issues.

So the short mental model is: client asks for a window, Spring builds a pageable request, the database returns just that window, and Spring optionally counts the full result set.

Real-World Story: Imagine an e-commerce product catalog service that powers the search page for a shopping app. The frontend shows 24 products per screen and requests /api/products?page=0&size=24&sort=createdAt,desc. The backend uses Spring Data pagination so the first screen loads fast, the “Next” button is cheap, and the UI can display “Showing 1–24 of 18,432”.

Now the bug: a developer assumed page numbers were 1-based and subtracted one in the controller, while the frontend already sent 0-based values. The first request became page -1 or mapped to the wrong offset, depending on the fix. Users reported duplicate products, missing first-page results, and a “Load more” button that seemed to jump around. In logs, the team saw repeated SQL like offset 24 on what was supposed to be the initial request, plus spikes in 400 Bad Request responses when size validation was added late.

What went wrong mattered because search pages are one of the highest-traffic endpoints in the system. A small pagination mistake turned into a visible UX problem, more support tickets, and a latency spike when the count query hit a huge joined table. The lesson: pagination is not just a controller detail; it is part of your data contract.

Spring Boot
package com.example.paginationdemo;

import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import java.math.BigDecimal;
import java.util.stream.IntStream;
import org.springframework.boot.CommandLineRunner;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Component;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;

@SpringBootApplication
public class PaginationApplication implements CommandLineRunner {

    private final ProductRepository productRepository;

    public PaginationApplication(ProductRepository productRepository) {
        this.productRepository = productRepository;
    }

    public static void main(String[] args) {
        SpringApplication.run(PaginationApplication.class, args);
    }

    @Override
    public void run(String... args) {
        // Seed data so you can hit the endpoint immediately and see several pages.
        if (productRepository.count() == 0) {
            IntStream.rangeClosed(1, 23).forEach(i ->
                    productRepository.save(new Product("Product " + i, BigDecimal.valueOf(i * 9.99)))
            );
        }
    }
}

@Entity
@Table(name = "products")
class Product {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String name;

    private BigDecimal price;

    protected Product() {
        // JPA needs a no-args constructor.
    }

    public Product(String name, BigDecimal price) {
        this.name = name;
        this.price = price;
    }

    public Long getId() {
        return id;
    }

    public String getName() {
        return name;
    }

    public BigDecimal getPrice() {
        return price;
    }
}

record ProductDto(Long id, String name, BigDecimal price) {
    static ProductDto from(Product product) {
        return new ProductDto(product.getId(), product.getName(), product.getPrice());
    }
}

interface ProductRepository extends JpaRepository<Product, Long> {
}

@RestController
@RequestMapping("/api/products")
class ProductController {

    private final ProductRepository productRepository;

    ProductController(ProductRepository productRepository) {
        this.productRepository = productRepository;
    }

    @GetMapping
    public Page<ProductDto> listProducts(
            @RequestParam(defaultValue = "0") int page,
            @RequestParam(defaultValue = "5") int size) {

        // Page numbers are zero-based in Spring Data by default.
        if (page < 0) {
            throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "page must be >= 0");
        }

        // A hard cap protects the API from huge requests and accidental abuse.
        if (size < 1 || size > 50) {
            throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "size must be between 1 and 50");
        }

        Pageable pageable = PageRequest.of(page, size, Sort.by("id").ascending());
        Page<Product> result = productRepository.findAll(pageable);

        // If the client asks for a page past the end, Spring Data returns an empty page.
        // That is useful because the API stays stable instead of crashing.
        return result.map(ProductDto::from);
    }
}

@Component
class SampleDataNote {
    // This class is intentionally empty.
    // It keeps the example in one file and reminds you that the data comes from the CommandLineRunner.
}

Follow-up & Tricky Questions:

  • How does Spring resolve a Pageable from the request? Spring MVC reads query parameters like page, size, and sort and turns them into a PageRequest. This works automatically in Spring Boot when Spring Data web support is on the classpath.
  • What is the difference between Page and Slice? Page includes total counts and total pages, while Slice only tells you whether there is a next or previous chunk. Use Slice when you do not need totals and want to avoid the count query.
  • How do you change the default page size? Globally, use properties like spring.data.web.pageable.default-page-size; locally, use @PageableDefault on the controller method parameter. That lets you choose a reasonable default without forcing every client to send size.
  • How do you sort by multiple fields? Pass repeated sort parameters such as sort=createdAt,desc&sort=id,desc or build a Sort object in code. Multiple fields are important for stable ordering in changing data.
  • When would you avoid offset pagination? For very deep pages, highly volatile feeds, or huge tables where skipping rows gets slow. In those cases, keyset or cursor pagination usually performs better.
  • Tricky: Is the first page 1 or 0? By default in Spring Data it is 0. You can switch to one-based parameters, but if you do not configure that, assuming page 1 is first is a common bug.
  • Tricky: Does Page<T> always cost only one query? No. It usually costs one query for the data and another for the total count, which is exactly why Slice<T> can be faster.
  • Tricky: Does pagination automatically guarantee consistent results? No. Without a stable sort, rows can move between pages when new data is inserted or updated. Pagination controls the window; sorting controls the order.

Common Mistakes:

  • Using 1-based page numbers by accident. Fix: remember Spring Data defaults to page=0, or explicitly configure one-based parameters if your API contract needs them.
  • Forgetting a stable sort. Fix: sort by a unique or near-unique field such as id or createdAt, id so records do not jump between pages.
  • Allowing huge page sizes. Fix: cap the size with validation or spring.data.web.pageable.max-page-size so one request cannot pull back too much data.
  • Returning entities directly everywhere. Fix: map to DTOs so you control the API contract and avoid lazy-loading surprises.

Memory Hook: Pagination is a library shelf: sort picks the aisle, page picks the shelf, and size says how many books you can carry.

Cheat Sheet:

  • Pageable is the request; Page<T> is the response.
  • Default page index is 0; default size is usually 20.
  • Page gives totals; Slice skips the count query.
  • Always sort, and prefer a stable sort.
  • Deep offset pages can get slow because the database must skip rows.
  • Use DTOs and cap the maximum page size for safer APIs.

Practice Tasks:

  • Add a sort query parameter and test name,desc and id,asc.
  • Change the endpoint to reject size > 20 and return a clear 400.
  • Replace Page<ProductDto> with Slice<ProductDto> and observe the smaller metadata shape.
Previous
Back to Questions←→to navigate
Next

Recommended Resources

Recommended Book

Cracking the Coding Interview

189 Programming Questions and Solutions

Recommended Book

Cracking the Coding Interview

189 Programming Questions and Solutions

package com.example.paginationdemo; import jakarta.persistence.Entity; import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; import jakarta.persistence.Id; import jakarta.persistence.Table; import java.math.BigDecimal; import java.util.stream.IntStream; import org.springframework.boot.CommandLineRunner; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Sort; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.http.HttpStatus; import org.springframework.stereotype.Component; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; import org.springframework.web.server.ResponseStatusException; @SpringBootApplication public class PaginationApplication implements CommandLineRunner { private final ProductRepository productRepository; public PaginationApplication(ProductRepository productRepository) { this.productRepository = productRepository; } public static void main(String[] args) { SpringApplication.run(PaginationApplication.class, args); } @Override public void run(String... args) { // Seed data so you can hit the endpoint immediately and see several pages. if (productRepository.count() == 0) { IntStream.rangeClosed(1, 23).forEach(i -> productRepository.save(new Product("Product " + i, BigDecimal.valueOf(i * 9.99))) ); } } } @Entity @Table(name = "products") class Product { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; private String name; private BigDecimal price; protected Product() { // JPA needs a no-args constructor. } public Product(String name, BigDecimal price) { this.name = name; this.price = price; } public Long getId() { return id; } public String getName() { return name; } public BigDecimal getPrice() { return price; } } record ProductDto(Long id, String name, BigDecimal price) { static ProductDto from(Product product) { return new ProductDto(product.getId(), product.getName(), product.getPrice()); } } interface ProductRepository extends JpaRepository<Product, Long> { } @RestController @RequestMapping("/api/products") class ProductController { private final ProductRepository productRepository; ProductController(ProductRepository productRepository) { this.productRepository = productRepository; } @GetMapping public Page<ProductDto> listProducts( @RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "5") int size) { // Page numbers are zero-based in Spring Data by default. if (page < 0) { throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "page must be >= 0"); } // A hard cap protects the API from huge requests and accidental abuse. if (size < 1 || size > 50) { throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "size must be between 1 and 50"); } Pageable pageable = PageRequest.of(page, size, Sort.by("id").ascending()); Page<Product> result = productRepository.findAll(pageable); // If the client asks for a page past the end, Spring Data returns an empty page. // That is useful because the API stays stable instead of crashing. return result.map(ProductDto::from); } } @Component class SampleDataNote { // This class is intentionally empty. // It keeps the example in one file and reminds you that the data comes from the CommandLineRunner. }