Pagination and Sorting with Pageable, Page, and Slice

Why Pagination Matters

Returning all rows from a database table is one of the most common production incidents. A query that works in development with 100 rows silently becomes a 10-second, 2 GB memory spike when production has 2 million rows. Pagination limits how many rows travel from the database to the application at a time.

Spring Data JPA makes pagination a first-class feature — pass a Pageable to any repository method and get a Page<T> back.


Core Types

TypeWhat it represents
PageableA request: page number, page size, sort
PageRequestThe standard Pageable implementation
Page<T>A page result: content + total count + metadata
Slice<T>A page result: content + has-next (no count query)
SortOrdering specification, independent of pagination

Creating a PageRequest

// Page 0, 20 items, unsorted
Pageable page = PageRequest.of(0, 20);

// Page 1, 10 items, sorted by name ascending
Pageable page = PageRequest.of(1, 10, Sort.by("name"));

// Multiple sort fields
Pageable page = PageRequest.of(0, 20,
    Sort.by(Sort.Direction.DESC, "createdAt")
        .and(Sort.by(Sort.Direction.ASC, "name"))
);

// Sort.by with typed direction
Pageable page = PageRequest.of(0, 20,
    Sort.by(Sort.Order.desc("price"), Sort.Order.asc("id"))
);

Page numbers are zero-based — page 0 is the first page.


Page — Full Pagination with Count

@Repository
public interface ProductRepository extends JpaRepository<Product, Long> {
    Page<Product> findByActiveTrue(Pageable pageable);
    Page<Product> findByCategoryId(Long categoryId, Pageable pageable);
}
@Service
@Transactional(readOnly = true)
public class ProductService {

    private final ProductRepository productRepository;

    public Page<Product> getProducts(int page, int size, String sortBy) {
        Pageable pageable = PageRequest.of(page, size, Sort.by(sortBy));
        return productRepository.findByActiveTrue(pageable);
    }
}

What’s in a Page

Page<Product> result = productRepository.findByActiveTrue(pageable);

result.getContent();          // List<Product> — the current page's data
result.getTotalElements();    // long — total rows across all pages
result.getTotalPages();       // int — total number of pages
result.getNumber();           // int — current page number (0-based)
result.getSize();             // int — page size requested
result.isFirst();             // boolean
result.isLast();              // boolean
result.hasNext();             // boolean
result.hasPrevious();         // boolean
result.getNumberOfElements(); // int — items in this page (may be < size on last page)

Page<T> runs two queries: the data query and a COUNT query. The count query is needed to calculate getTotalPages() and getTotalElements(). This is the right choice for most pagination UIs that show “Page 3 of 47”.


Slice — Efficient “Load More” Pagination

When you don’t need the total count — such as infinite scroll or “load more” buttons — use Slice<T>:

@Repository
public interface ProductRepository extends JpaRepository<Product, Long> {
    Slice<Product> findByActiveTrue(Pageable pageable);
}
Slice<Product> slice = productRepository.findByActiveTrue(pageable);

slice.getContent();   // List<Product> — current page data
slice.hasNext();      // boolean — is there a next page?
slice.hasPrevious();  // boolean
slice.getNumber();    // int — current page

Slice<T> runs only one query — it fetches size + 1 rows to determine if there’s a next page, but doesn’t run a COUNT. For large tables, this can be significantly faster.

When to Use Each

Use Page<T> whenUse Slice<T> when
You show “Page N of M”You show “Load more”
You need total record countTotal count not required
Admin tables, reportsInfinite scroll, feeds
Count query is fastTable is huge, COUNT is slow

Sorting Without Pagination

Use Sort alone when you want all results ordered but not paginated:

@Repository
public interface ProductRepository extends JpaRepository<Product, Long> {
    List<Product> findByActiveTrue(Sort sort);
}
List<Product> products = productRepository.findByActiveTrue(
    Sort.by(Sort.Order.asc("name"))
);
// Dynamic sort from user input — always validate the field name
public List<Product> getProductsSorted(String field, String direction) {
    Sort.Direction dir = "desc".equalsIgnoreCase(direction)
        ? Sort.Direction.DESC
        : Sort.Direction.ASC;
    return productRepository.findByActiveTrue(Sort.by(dir, field));
}

Security note: never pass user-supplied sort field names directly without validation. An attacker could inject arbitrary column names. Validate against an allowlist:

private static final Set<String> ALLOWED_SORT_FIELDS =
    Set.of("name", "price", "createdAt", "rating");

public Page<Product> getProducts(String sortField, int page, int size) {
    if (!ALLOWED_SORT_FIELDS.contains(sortField)) {
        sortField = "name"; // safe default
    }
    Pageable pageable = PageRequest.of(page, size, Sort.by(sortField));
    return productRepository.findByActiveTrue(pageable);
}

Pagination with @Query

@Query("""
    SELECT p FROM Product p
    JOIN FETCH p.category c
    WHERE c.slug = :slug AND p.active = true
    """,
    countQuery = """
    SELECT COUNT(p) FROM Product p
    JOIN p.category c
    WHERE c.slug = :slug AND p.active = true
    """
)
Page<Product> findByCategorySlug(@Param("slug") String slug, Pageable pageable);

Always supply a countQuery when your main query uses JOIN FETCH — Hibernate cannot derive an efficient count from a fetch join.


REST Controller Pattern

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

    private final ProductService productService;

    @GetMapping
    public Page<ProductDto> getProducts(
        @RequestParam(defaultValue = "0")   int page,
        @RequestParam(defaultValue = "20")  int size,
        @RequestParam(defaultValue = "name") String sort,
        @RequestParam(defaultValue = "asc")  String direction
    ) {
        return productService.getProducts(page, size, sort, direction)
                             .map(ProductDto::from);
    }
}

Spring Boot automatically handles Page<T> serialization to JSON:

{
  "content": [...],
  "pageable": {
    "pageNumber": 0,
    "pageSize": 20,
    "sort": { "sorted": true, "orders": [{"property": "name", "direction": "ASC"}] }
  },
  "totalPages": 12,
  "totalElements": 230,
  "last": false,
  "first": true,
  "numberOfElements": 20,
  "size": 20,
  "number": 0
}

Using Pageable as a Controller Parameter Directly

Spring MVC can resolve Pageable directly from request parameters:

@GetMapping
public Page<ProductDto> getProducts(
    @PageableDefault(size = 20, sort = "name", direction = Sort.Direction.ASC)
    Pageable pageable
) {
    return productService.findAll(pageable).map(ProductDto::from);
}

With @PageableDefault, the URL GET /api/products?page=2&size=10&sort=price,desc automatically creates the right Pageable. Enable this by adding spring-data-web support (included by default in Spring Boot):

@Configuration
@EnableSpringDataWebSupport(pageSerializationMode = VIA_DTO)
public class WebConfig {}

The VIA_DTO mode serializes Page as a plain DTO, avoiding Spring’s verbose default serialization.


Keyset Pagination (Seek Method) for Large Datasets

Standard offset pagination (LIMIT x OFFSET y) has a performance problem: the database must scan and discard all y rows before returning x results. Page 500 of a large table can be very slow.

Keyset pagination (also called seek method) avoids this by filtering by the last-seen value:

// Standard offset — slow at high page numbers
SELECT * FROM products ORDER BY created_at DESC LIMIT 20 OFFSET 1000;

// Keyset — always fast, reads from the index directly
SELECT * FROM products
WHERE created_at < :lastSeenCreatedAt
  OR (created_at = :lastSeenCreatedAt AND id < :lastSeenId)
ORDER BY created_at DESC, id DESC
LIMIT 20;
@Query("""
    SELECT p FROM Product p
    WHERE p.active = true
      AND (p.createdAt < :lastCreatedAt
           OR (p.createdAt = :lastCreatedAt AND p.id < :lastId))
    ORDER BY p.createdAt DESC, p.id DESC
    """)
List<Product> findPageAfter(
    @Param("lastCreatedAt") LocalDateTime lastCreatedAt,
    @Param("lastId") Long lastId,
    Pageable pageable
);

Use keyset pagination when:

  • You have millions of rows
  • You need consistent performance regardless of page number
  • You can tolerate forward-only navigation (no “jump to page 50”)

Avoiding Common Pagination Mistakes

1. Forgetting to Map Before Returning

Never return JPA entities from REST controllers — they may trigger lazy-loading during JSON serialization (with an open session), or throw LazyInitializationException (without one):

// Wrong — returns entity, risky serialization
return productRepository.findByActiveTrue(pageable);

// Right — map to DTO
return productRepository.findByActiveTrue(pageable).map(ProductDto::from);

Page.map() applies a mapping function to each element while preserving all pagination metadata.

2. Sorting by Non-Indexed Columns

Sorting forces a full table scan unless the sort column is indexed. Always index columns you sort by in paginated queries:

CREATE INDEX idx_products_created_at ON products(created_at DESC);
CREATE INDEX idx_products_price ON products(price);

3. Requesting Too-Large Pages

// Dangerous — a client could request page size = 1,000,000
@GetMapping
public Page<Product> getProducts(@RequestParam int size, ...) { ... }

// Safe — cap the page size
public Page<Product> getProducts(@RequestParam(defaultValue = "20") int size, ...) {
    size = Math.min(size, 100); // never return more than 100 per page
    ...
}

4. N+1 on Paginated Results

// Each product triggers a separate query to load its category
Page<Product> page = productRepository.findByActiveTrue(pageable);
page.getContent().forEach(p -> System.out.println(p.getCategory().getName())); // N+1

Use JOIN FETCH in the query or @EntityGraph (Article 27) to load associations in one query.


Summary

  • Pageable carries page number, size, and sort — create via PageRequest.of().
  • Page<T> includes total count (two queries); Slice<T> skips the count (one query).
  • Supply an explicit countQuery when your data query uses JOIN FETCH.
  • Validate user-supplied sort fields against an allowlist to prevent injection.
  • For large tables, consider keyset pagination over offset pagination for consistent performance.
  • Always map entities to DTOs using Page.map() before returning from controllers.

Next: Article 20 covers projections and DTOs — how to fetch only the columns you need using interface projections, class-based projections, and dynamic projections.