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
| Type | What it represents |
|---|---|
Pageable | A request: page number, page size, sort |
PageRequest | The standard Pageable implementation |
Page<T> | A page result: content + total count + metadata |
Slice<T> | A page result: content + has-next (no count query) |
Sort | Ordering 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> when | Use Slice<T> when |
|---|---|
| You show “Page N of M” | You show “Load more” |
| You need total record count | Total count not required |
| Admin tables, reports | Infinite scroll, feeds |
| Count query is fast | Table 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
Pageablecarries page number, size, and sort — create viaPageRequest.of().Page<T>includes total count (two queries);Slice<T>skips the count (one query).- Supply an explicit
countQuerywhen 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.