JavaSpring BootBackend

Spring Boot Pagination and Sorting — Complete Guide

Spring boot pagination and sorting is the difference between an API that works with ten records and one that remains useful when it has ten million. A product catalog, support dashboard, customer list, audit log, or admin panel rarely needs every row at once. Returning a massive dataset slows the database, uses unnecessary network bandwidth, makes browser interfaces sluggish, and creates an awkward experience for the person trying to find one item.

In this complete guide, you will build a PostgreSQL-backed product API with Java 21, Spring Boot 3.x, Spring Data JPA, and Gradle. You will use Pageable, PageRequest, and Sort; design a stable pagination response; add validation and global error handling; seed PostgreSQL data; and test everything with Postman and cURL.

For the controller basics used here, read Spring Boot REST API Guide. For the persistence foundation, see Spring Boot PostgreSQL CRUD with JPA and Hibernate.
Table of Contents

Why Pagination Matters

Imagine an ecommerce back office with 250,000 products. A plain findAll() endpoint makes PostgreSQL read all rows, Hibernate create a Java object for each row, Spring serialize every object, and the network send an oversized JSON document. Even if it succeeds on a laptop, it wastes resources and risks timeouts in production. Pagination asks for a bounded slice instead: perhaps 20 items at a time.

Real-world examples are everywhere: a banking app pages transactions by newest first, a CRM pages contacts by name, a warehouse dashboard pages products by stock quantity, and an audit screen pages events by timestamp. Sorting makes each view useful. A manager may sort low stock first; a buyer may sort products by price; an operator may want the newest records first.

Pagination also creates a clear user experience. A frontend can display page controls, show the total result count, and request only the next result set when it is needed. It is a resource-control feature as much as a UI feature.

What Is Pagination?

Pagination divides a collection into numbered chunks. In Spring Data, page numbering is zero-based by default, so page=0 requests the first page.

  • Page: The zero-based chunk number. Page 0 is the first page.
  • Size: The maximum number of rows in one response.
  • Offset: The number of rows skipped before the current page. With page 2 and size 20, the offset is 40.
  • Limit: The maximum rows returned. In SQL this corresponds to the page size.

For a request of ?page=1&size=20, Spring Data ultimately asks PostgreSQL for 20 records after skipping the first 20. Offset pagination is easy to explain and ideal for many admin APIs. Very deep pages can become expensive because the database still has to walk past skipped rows; later in this guide we will cover production alternatives.

What Is Sorting?

Sorting determines the order before a page is selected. Ascending order, written ASC, moves low values to high values or A to Z. Descending order, DESC, moves high values to low values or newest timestamps first. A stable sort matters: if the order changes between requests, a record can appear twice or disappear while the user moves through pages.

Multiple-field sorting resolves ties. For example, sort products by category ASC and then name ASC. For pagination, include a unique final field such as id ASC or id DESC. That gives two records with equal timestamps or prices a deterministic order.

Project Setup

Create a Gradle project with Java 21 and Spring Boot 3.x. Add Spring Web, Spring Data JPA, Validation, and the PostgreSQL driver. This is a conventional spring data jpa pagination setup; no special pagination library is required.

plugins {
    id 'java'
    id 'org.springframework.boot' version '3.3.5'
    id 'io.spring.dependency-management' version '1.1.6'
}

group = 'com.digitaldrift'
version = '0.0.1-SNAPSHOT'

java {
    toolchain {
        languageVersion = JavaLanguageVersion.of(21)
    }
}

repositories { mavenCentral() }

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-web'
    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
    implementation 'org.springframework.boot:spring-boot-starter-validation'
    runtimeOnly 'org.postgresql:postgresql'
    testImplementation 'org.springframework.boot:spring-boot-starter-test'
}

tasks.named('test') { useJUnitPlatform() }
spring:
  datasource:
    url: jdbc:postgresql://localhost:5432/product_db
    username: product_user
    password: product_password
  jpa:
    hibernate:
      ddl-auto: update
    properties:
      hibernate:
        format_sql: true
server:
  port: 8080

Use environment variables or a secret manager instead of literal credentials in production. Also replace ddl-auto: update with Flyway or Liquibase migrations before deploying.

Folder Structure

src/main/java/com/digitaldrift/catalog/
├── CatalogApplication.java
├── controller/ProductController.java
├── dto/ProductRequest.java
├── dto/ProductResponse.java
├── dto/PagedResponse.java
├── entity/Product.java
├── exception/GlobalExceptionHandler.java
├── exception/ResourceNotFoundException.java
├── repository/ProductRepository.java
└── service/ProductService.java

Complete Implementation

Create Product Entity

The entity is the PostgreSQL table model. Store money as BigDecimal, not double, to avoid floating-point surprises. The entity also keeps timestamps that make a useful default sort.

package com.digitaldrift.catalog.entity;

import jakarta.persistence.*;
import java.math.BigDecimal;
import java.time.Instant;

@Entity
@Table(name = "products")
public class Product {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false, length = 120)
    private String name;

    @Column(nullable = false, precision = 10, scale = 2)
    private BigDecimal price;

    @Column(nullable = false)
    private Integer stockQuantity;

    @Column(nullable = false, length = 80)
    private String category;

    @Column(nullable = false, updatable = false)
    private Instant createdAt = Instant.now();

    protected Product() { }

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

    public Long getId() { return id; }
    public String getName() { return name; }
    public BigDecimal getPrice() { return price; }
    public Integer getStockQuantity() { return stockQuantity; }
    public String getCategory() { return category; }
    public Instant getCreatedAt() { return createdAt; }
}

Create DTOs and Validation

Do not return JPA entities directly. A request DTO defines what the client may send; a response DTO defines the stable public shape. Validation belongs at the API boundary, before expensive persistence work begins.

package com.digitaldrift.catalog.dto;

import jakarta.validation.constraints.DecimalMin;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import java.math.BigDecimal;

public record ProductRequest(
        @NotBlank @Size(max = 120) String name,
        @NotNull @DecimalMin("0.01") BigDecimal price,
        @NotNull @Min(0) Integer stockQuantity,
        @NotBlank @Size(max = 80) String category
) { }
package com.digitaldrift.catalog.dto;

import com.digitaldrift.catalog.entity.Product;
import java.math.BigDecimal;
import java.time.Instant;

public record ProductResponse(
        Long id, String name, BigDecimal price, Integer stockQuantity,
        String category, Instant createdAt
) {
    public static ProductResponse from(Product product) {
        return new ProductResponse(product.getId(), product.getName(), product.getPrice(),
                product.getStockQuantity(), product.getCategory(), product.getCreatedAt());
    }
}

Pagination Response Model

Spring's Page object is excellent internally, but its JSON format exposes framework details and can vary with configuration. A custom response makes the API contract deliberate and easy for frontend teams to consume.

package com.digitaldrift.catalog.dto;

import org.springframework.data.domain.Page;
import java.util.List;

public record PagedResponse<T>(
        List<T> content,
        int page,
        int size,
        long totalElements,
        int totalPages,
        boolean first,
        boolean last,
        boolean hasNext,
        boolean hasPrevious
) {
    public static <T> PagedResponse<T> from(Page<T> result) {
        return new PagedResponse<>(
                result.getContent(), result.getNumber(), result.getSize(),
                result.getTotalElements(), result.getTotalPages(), result.isFirst(),
                result.isLast(), result.hasNext(), result.hasPrevious()
        );
    }
}

Create Repository Layer

JpaRepository already includes the pagination and sorting methods from PagingAndSortingRepository. In older tutorials you may see those interfaces extended separately. With current Spring Data, extending JpaRepository<Product, Long> gives you CRUD, findAll(Pageable), and sorting support.

package com.digitaldrift.catalog.repository;

import com.digitaldrift.catalog.entity.Product;
import org.springframework.data.jpa.repository.JpaRepository;

public interface ProductRepository extends JpaRepository<Product, Long> {
}

Create Service Layer

The service owns the pagination policy. It limits request size, allowlists sortable fields, and appends id as a tie-breaker. This matters because clients should not be allowed to sort arbitrary entity properties or request an accidental page of 100,000 rows.

package com.digitaldrift.catalog.service;

import com.digitaldrift.catalog.dto.PagedResponse;
import com.digitaldrift.catalog.dto.ProductResponse;
import com.digitaldrift.catalog.entity.Product;
import com.digitaldrift.catalog.repository.ProductRepository;
import java.util.Set;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Sort;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
public class ProductService {
    private static final int DEFAULT_PAGE_SIZE = 20;
    private static final int MAX_PAGE_SIZE = 100;
    private static final Set<String> ALLOWED_SORT_FIELDS =
            Set.of("id", "name", "price", "stockQuantity", "category", "createdAt");

    private final ProductRepository productRepository;

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

    @Transactional(readOnly = true)
    public PagedResponse<ProductResponse> getProducts(
            int page, Integer requestedSize, String sortBy, String direction
    ) {
        int size = requestedSize == null ? DEFAULT_PAGE_SIZE : requestedSize;
        if (page < 0) throw new IllegalArgumentException("Page must be zero or greater");
        if (size < 1 || size > MAX_PAGE_SIZE) {
            throw new IllegalArgumentException("Page size must be between 1 and " + MAX_PAGE_SIZE);
        }
        if (!ALLOWED_SORT_FIELDS.contains(sortBy)) {
            throw new IllegalArgumentException("Unsupported sort field: " + sortBy);
        }

        Sort.Direction sortDirection = "asc".equalsIgnoreCase(direction)
                ? Sort.Direction.ASC : Sort.Direction.DESC;
        Sort sort = Sort.by(sortDirection, sortBy).and(Sort.by(sortDirection, "id"));
        PageRequest pageRequest = PageRequest.of(page, size, sort);
        Page<ProductResponse> result = productRepository.findAll(pageRequest)
                .map(ProductResponse::from);

        return PagedResponse.from(result);
    }
}

Create Controller Layer

This spring boot pageable example exposes understandable query parameters while keeping policy in the service. The controller provides defaults: page zero, 20 records, newest products first.

package com.digitaldrift.catalog.controller;

import com.digitaldrift.catalog.dto.PagedResponse;
import com.digitaldrift.catalog.dto.ProductResponse;
import com.digitaldrift.catalog.service.ProductService;
import org.springframework.http.ResponseEntity;
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;

@RestController
@RequestMapping("/api/products")
public class ProductController {
    private final ProductService productService;

    public ProductController(ProductService productService) {
        this.productService = productService;
    }

    @GetMapping
    public ResponseEntity<PagedResponse<ProductResponse>> getProducts(
            @RequestParam(defaultValue = "0") int page,
            @RequestParam(required = false) Integer size,
            @RequestParam(defaultValue = "createdAt") String sortBy,
            @RequestParam(defaultValue = "desc") String direction
    ) {
        return ResponseEntity.ok(productService.getProducts(page, size, sortBy, direction));
    }
}

Implement Pagination with Pageable and PageRequest

Pageable is the Spring Data interface that describes a requested page. PageRequest is the common immutable implementation. In the service above, PageRequest.of(page, size, sort) contains all three concerns in one object. Passing it to repository.findAll(pageRequest) makes Hibernate generate a limited query plus a count query for the total number of rows.

Some controllers accept Pageable pageable directly. That is concise and works well internally:

@GetMapping("/internal")
public Page<ProductResponse> list(Pageable pageable) {
    return productRepository.findAll(pageable).map(ProductResponse::from);
}

For a public API, explicit parameters and a controlled PageRequest are often clearer. They let you enforce a maximum size and validate sort fields in one place.

Implement Sorting with Sort.by()

A spring boot sorting example begins with Sort.by("price"). Add a direction with Sort.by(Sort.Direction.ASC, "price") or Sort.by(Sort.Direction.DESC, "createdAt"). For multiple fields, chain orders:

Sort sort = Sort.by("category").ascending()
        .and(Sort.by("name").ascending())
        .and(Sort.by("id").ascending());

The appended ID gives a stable order when two products have the same category and name. Choose a default sort deliberately. A page without a defined order can look correct in development and become unpredictable after a database plan changes.

Pagination and Sorting Together

The endpoint combines both features:

GET /api/products?page=0&size=20&sortBy=price&direction=asc

The service produces a PageRequest with an ascending price sort and a secondary ascending ID sort. PostgreSQL returns the first 20 records in that deterministic order. The API then maps the content into DTOs and returns pagination metadata with it.

API Endpoints

  • Default page: GET /api/products
  • Pagination only: GET /api/products?page=2&size=10
  • Sorting only: GET /api/products?sortBy=name&direction=asc
  • Combined: GET /api/products?page=0&size=25&sortBy=stockQuantity&direction=asc

Example JSON Response

{
  "content": [
    {
      "id": 14,
      "name": "USB-C Dock",
      "price": 89.99,
      "stockQuantity": 4,
      "category": "Accessories",
      "createdAt": "2026-06-24T08:15:00Z"
    },
    {
      "id": 7,
      "name": "Wireless Mouse",
      "price": 49.99,
      "stockQuantity": 9,
      "category": "Accessories",
      "createdAt": "2026-06-23T09:30:00Z"
    }
  ],
  "page": 0,
  "size": 20,
  "totalElements": 128,
  "totalPages": 7,
  "first": true,
  "last": false,
  "hasNext": true,
  "hasPrevious": false
}

PostgreSQL Sample Data

Populate the table with a few products before testing. In a real project, use Flyway seed migrations or a local development fixture rather than manually editing production data.

INSERT INTO products (name, price, stock_quantity, category, created_at)
VALUES
  ('Mechanical Keyboard', 129.99, 25, 'Accessories', NOW()),
  ('Wireless Mouse', 49.99, 9, 'Accessories', NOW()),
  ('27-inch Monitor', 329.00, 12, 'Displays', NOW()),
  ('Laptop Stand', 39.50, 46, 'Accessories', NOW()),
  ('Webcam', 79.99, 4, 'Accessories', NOW());

When queries frequently sort by a field, PostgreSQL needs an index that supports the actual query pattern. More on that shortly.

Global Exception Handling Integration

The service throws IllegalArgumentException for negative pages, oversized page sizes, and unapproved sort fields. A global handler turns those into consistent 400 Bad Request JSON instead of an unhelpful server error.

@ExceptionHandler(IllegalArgumentException.class)
public ResponseEntity<ErrorResponse> handleIllegalArgument(
        IllegalArgumentException exception,
        HttpServletRequest request
) {
    ErrorResponse response = new ErrorResponse(
            Instant.now(), 400, "Bad Request", "INVALID_ARGUMENT",
            exception.getMessage(), request.getRequestURI(), Map.of()
    );
    return ResponseEntity.badRequest().body(response);
}

For the full @ControllerAdvice setup, validation errors, and 404/409/500 responses, read Spring Boot Global Exception Handling. Authentication and authorization errors are covered in the Spring Boot JWT Authentication Guide.

Performance Benefits and Production Considerations

Pagination reduces the amount of data transferred and the amount of Java heap used per request. It also protects a database from expensive accidental scans. But it is not magic: the count query used to calculate totalElements can still be costly on a large filtered table, and deep offset pages make the database skip many rows.

Database Indexing

Create indexes based on real filters and sorts. For a common category filter ordered by newest first, a composite index can help:

CREATE INDEX idx_products_category_created_at_id
ON products (category, created_at DESC, id DESC);

Do not index every column. Each index consumes storage and makes inserts or updates more expensive. Use PostgreSQL EXPLAIN ANALYZE against real query shapes before adding one.

Query Optimization and Caching

Return only fields a screen needs. DTO projections can outperform loading a wide entity graph for read-heavy endpoints. Cache public, slowly changing pages only when cache invalidation is understood; a product inventory page changes often and may not be a good cache candidate. Monitor database latency, count-query time, and popular sort combinations.

Deep Pagination

Offset pagination is a sound default for browse screens. For endless feeds or very deep exports, consider keyset pagination, sometimes called seek pagination. Instead of asking for page 10,000, a client asks for records after a known createdAt/id cursor. It avoids a large offset but needs a cursor-style API and stable ordering.

Common Mistakes

Allowing a Huge Page Size

Never trust a client-provided size. A maximum of 100 is a reasonable initial guardrail. Tune it after observing payload size, query performance, and your actual user interface.

Missing a Default Sort

Without an explicit order, successive page calls can return records in a different order. Default to a timestamp and unique ID, or another business-defined stable order.

Returning Entities Directly

Entities are persistence models, not an API contract. Returning them can expose columns, lazy relationships, and internal changes. Map to DTOs before building a page response.

Accepting Any Sort Property

Even when Spring safely builds the query, unrestricted fields turn your API into an undocumented persistence surface. Use an allowlist and tell clients which fields are supported.

Best Practices

  • Use DTOs: Keep the JSON contract independent from JPA entities.
  • Be consistent: Use the same parameter names and response metadata across list endpoints.
  • Set defaults: A request without query parameters should still be bounded and stable.
  • Enforce a maximum: Put the upper limit on the server, not only in documentation.
  • Validate sorting: Allow only known, indexed, meaningful fields.
  • Include metadata: Clients need total pages and next/previous flags to build reliable pagination controls.
  • Document zero-based pages: It removes a frequent frontend integration mistake.

Postman Testing

Run the API with ./gradlew bootRun and create a Postman collection with these requests:

  • GET http://localhost:8080/api/products should return page 0 with size 20.
  • GET http://localhost:8080/api/products?page=1&size=2 should return the second two-record page.
  • GET http://localhost:8080/api/products?sortBy=price&direction=asc should put the lowest price first.
  • GET http://localhost:8080/api/products?size=500 should return 400 with INVALID_ARGUMENT.
  • GET http://localhost:8080/api/products?sortBy=password should return 400 because the field is not allowlisted.

cURL Examples

curl "http://localhost:8080/api/products?page=0&size=10"

curl "http://localhost:8080/api/products?sortBy=name&direction=asc"

curl "http://localhost:8080/api/products?page=0&size=25&sortBy=stockQuantity&direction=asc"

curl "http://localhost:8080/api/products?size=500"

Frequently Asked Questions

What is the difference between Page and Slice?

A Page includes total element and total page counts, which normally requires a count query. A Slice only tells you whether a next slice exists, so it can be cheaper for some feed-style endpoints.

Should API page numbers start at zero or one?

Spring Data uses zero by default. You can expose one-based numbers, but converting them consistently adds work. Pick one convention, document it, and do not mix both.

Can I paginate a filtered query?

Yes. Add repository methods that accept both the filter and Pageable, such as Page<Product> findByCategory(String category, Pageable pageable).

Is offset pagination always slow?

No. It is efficient for common shallow pages with appropriate indexes. It becomes less attractive for large offsets or highly active datasets, where cursor pagination may be better.

Interview Questions and Answers

  • What is Pageable? A Spring Data abstraction containing page, size, and sort instructions.
  • What does PageRequest do? It is the standard implementation used to create a pageable request.
  • What does JpaRepository add? It combines CRUD, paging, sorting, flushing, and JPA convenience operations.
  • Why use a secondary sort field? It gives deterministic ordering when primary values are equal.
  • Why cap page size? To protect database, network, and memory resources from expensive requests.
  • What is keyset pagination? A cursor-based technique that fetches rows after a known sorted key instead of skipping an offset.
  • Why is a custom page response useful? It keeps the public API contract stable and framework-independent.

Conclusion

Spring Boot pagination and sorting is a small feature with a large production impact. You built a bounded, stable product listing API with PostgreSQL, Spring Data JPA, PageRequest, Sort, DTOs, validation, an allowlisted sort policy, and a custom pagination response.

Carry this same pattern to users, orders, transactions, logs, and any other growing dataset. Start with a sensible default page, a clear sort, a strict maximum size, and measured indexes. Your API will be faster, easier to consume, and much calmer under real load.

Dhiraj Roy
Dhiraj Roy

Backend developer & tech writer. Writing about Java, Spring Boot, Python, and AI at Digital Drift.