Spring Boot
Pagination & Sorting
Spring Data JPA provides first-class pagination and sorting through the Pageable abstraction, Page<T>, Slice<T>, and Sort. Repositories accept Pageable directly and return a Page containing the data slice plus metadata. This entry covers Pageable configuration, repository usage, custom page responses, sorting strategies, cursor-based pagination, and performance considerations.
Pageable and Page
Pageable carries the page index, page size, and sort instructions. Pass it to any repository method and Spring Data generates the correct LIMIT / OFFSET SQL and a COUNT query automatically. Page<T> wraps the result slice with total element count, total page count, and navigation flags.
Java
// ── Repository ────────────────────────────────────────────────────────
public interface ProductRepository
extends JpaRepository<Product, Long> {
// Derived query — Pageable applied automatically
Page<Product> findByStatus(ProductStatus status, Pageable pageable);
// JPQL — pagination applied to the query
@Query("SELECT p FROM Product p WHERE p.category.id = :categoryId")
Page<Product> findByCategory(
@Param("categoryId") Long categoryId,
Pageable pageable);
// Native query — countQuery is required for Page return type
@Query(
value = "SELECT * FROM products WHERE status = :status",
countQuery = "SELECT COUNT(*) FROM products WHERE status = :status",
nativeQuery = true
)
Page<Product> findByStatusNative(
@Param("status") String status,
Pageable pageable);
}
// ── Service ───────────────────────────────────────────────────────────
@Service
@RequiredArgsConstructor
public class ProductService {
private final ProductRepository productRepo;
public Page<ProductResponse> findByStatus(
ProductStatus status, Pageable pageable) {
return productRepo
.findByStatus(status, pageable)
.map(ProductResponse::from); // Page.map preserves metadata
}
}
// ── Constructing Pageable programmatically ────────────────────────────
Pageable first20 = PageRequest.of(0, 20);
Pageable sortedByName = PageRequest.of(0, 20,
Sort.by(Sort.Direction.ASC, "name"));
Pageable multiSort = PageRequest.of(0, 20,
Sort.by(Sort.Order.desc("createdAt"),
Sort.Order.asc("name")));
Pageable unpaged = Pageable.unpaged(); // no LIMIT — returns all rowsSlice — Skip the COUNT Query
Slice<T> is a lighter alternative to Page<T>. It executes only the data query — no COUNT — and returns hasNext() by fetching one extra row. Use it for infinite scroll and load-more UIs where the total count is not needed, avoiding the potentially expensive COUNT on large tables.
Java
// ── Repository returning Slice ────────────────────────────────────────
public interface PostRepository
extends JpaRepository<Post, Long> {
Slice<Post> findByAuthorId(Long authorId, Pageable pageable);
@Query("SELECT p FROM Post p WHERE p.status = 'PUBLISHED' " +
"ORDER BY p.publishedAt DESC")
Slice<Post> findPublished(Pageable pageable);
}
// ── Service ───────────────────────────────────────────────────────────
@Service
@RequiredArgsConstructor
public class PostService {
private final PostRepository postRepo;
public SliceResponse<PostSummary> findPublished(Pageable pageable) {
Slice<Post> slice = postRepo.findPublished(pageable);
return SliceResponse.from(slice.map(PostSummary::from));
}
}
// ── Slice response envelope ───────────────────────────────────────────
public record SliceResponse<T>(
List<T> content,
int page,
int size,
boolean hasNext,
boolean hasPrevious
) {
public static <T> SliceResponse<T> from(Slice<T> slice) {
return new SliceResponse<>(
slice.getContent(),
slice.getNumber(),
slice.getSize(),
slice.hasNext(),
slice.hasPrevious()
);
}
}
// ── How Slice detects hasNext ─────────────────────────────────────────
// Spring Data fetches pageSize + 1 rows.
// If the extra row is present → hasNext = true (extra row discarded)
// If fewer than pageSize rows → hasNext = false
// One extra row is the only overhead vs a plain List query.Sort
Sort carries one or more field-direction pairs. It can be passed standalone to repository methods that return List<T>, or embedded inside Pageable for paginated queries. Sort.Order provides fine-grained control over null handling and case sensitivity.
Java
// ── Repository accepting standalone Sort ─────────────────────────────
public interface UserRepository
extends JpaRepository<User, Long> {
List<User> findByDepartment(String department, Sort sort);
List<User> findAll(Sort sort);
}
// ── Constructing Sort ─────────────────────────────────────────────────
Sort byName = Sort.by("name"); // ASC default
Sort byNameDesc = Sort.by(Sort.Direction.DESC, "name");
Sort multi = Sort.by(
Sort.Order.desc("createdAt"),
Sort.Order.asc("name").ignoreCase(),
Sort.Order.asc("id").nullsLast()
);
Sort unsorted = Sort.unsorted();
// ── JpaSort for JPQL functions ────────────────────────────────────────
// Standard Sort uses field names — JPQL functions require JpaSort:
Sort byNameLength = JpaSort.unsafe("LENGTH(name)");
// ── Repository with JpaSort ───────────────────────────────────────────
public interface ProductRepository
extends JpaRepository<Product, Long> {
@Query("SELECT p FROM Product p")
List<Product> findAllSorted(Sort sort);
}
// ── Service ───────────────────────────────────────────────────────────
@Service
@RequiredArgsConstructor
public class UserService {
private final UserRepository userRepo;
public List<UserResponse> findByDepartment(
String department, Sort sort) {
return userRepo
.findByDepartment(department, sort)
.stream()
.map(UserResponse::from)
.toList();
}
}Custom Page Response Envelope
Spring Data's Page<T> serialises to a verbose JSON structure that includes internal Spring Data fields. Wrap it in a clean, stable record before returning it from a controller. This decouples the API contract from Spring Data internals and makes the response shape explicit and easy to document.
Java
// ── Clean page envelope ──────────────────────────────────────────────
public record PageResponse<T>(
List<T> content,
int page, // zero-based page index
int size, // requested page size
long totalElements, // total matching records
int totalPages, // total number of pages
boolean first, // is this the first page?
boolean last // is this the last page?
) {
public static <T> PageResponse<T> from(Page<T> page) {
return new PageResponse<>(
page.getContent(),
page.getNumber(),
page.getSize(),
page.getTotalElements(),
page.getTotalPages(),
page.isFirst(),
page.isLast()
);
}
}
// ── Controller ────────────────────────────────────────────────────────
@RestController
@RequestMapping("/api/v1/products")
@RequiredArgsConstructor
public class ProductController {
private final ProductService productService;
@GetMapping
public ResponseEntity<PageResponse<ProductResponse>> findAll(
@PageableDefault(
size = 20,
sort = "createdAt",
direction = Sort.Direction.DESC
) Pageable pageable) {
Page<ProductResponse> page = productService.findAll(pageable);
return ResponseEntity.ok(PageResponse.from(page));
}
}
// ── JSON output ───────────────────────────────────────────────────────
// {
// "content": [ ... ],
// "page": 0,
// "size": 20,
// "totalElements": 243,
// "totalPages": 13,
// "first": true,
// "last": false
// }Sorting with Field Whitelisting
Never pass client-supplied sort field names directly to JPA. A malicious client could sort by a sensitive column, trigger a costly expression, or probe the schema. Validate sort fields against an explicit allowlist before building the Pageable passed to the repository.
Java
// ── Allowlist validator ───────────────────────────────────────────────
@Component
public class SortFieldValidator {
public Pageable validate(Pageable pageable,
Set<String> allowedFields) {
List<Sort.Order> safe = pageable.getSort()
.stream()
.filter(order -> allowedFields.contains(order.getProperty()))
.toList();
Sort safeSort = safe.isEmpty()
? Sort.by(Sort.Direction.DESC, "createdAt") // safe default
: Sort.by(safe);
return PageRequest.of(
pageable.getPageNumber(),
pageable.getPageSize(),
safeSort);
}
}
// ── Controller using the validator ────────────────────────────────────
@RestController
@RequestMapping("/api/v1/users")
@RequiredArgsConstructor
public class UserController {
private final UserService userService;
private final SortFieldValidator sortValidator;
private static final Set<String> SORTABLE = Set.of(
"id", "name", "email", "createdAt", "status"
);
@GetMapping
public ResponseEntity<PageResponse<UserResponse>> findAll(
@PageableDefault(size = 20) Pageable pageable) {
Pageable safe = sortValidator.validate(pageable, SORTABLE);
return ResponseEntity.ok(
PageResponse.from(userService.findAll(safe)));
}
}
// ── DTO field → entity field mapping ─────────────────────────────────
// When DTO field names differ from entity field names, map before
// passing to the repository:
@Component
public class UserSortMapper {
private static final Map<String, String> MAP = Map.of(
"fullName", "name",
"joinDate", "createdAt",
"dept", "department.name"
);
public Sort toEntitySort(Sort dtoSort) {
return Sort.by(dtoSort.stream()
.map(o -> {
String field = MAP.getOrDefault(
o.getProperty(), o.getProperty());
return o.isAscending()
? Sort.Order.asc(field)
: Sort.Order.desc(field);
})
.toList());
}
}Cursor-Based Pagination
Offset pagination degrades on deep pages because the database must scan and discard all preceding rows. Cursor-based (keyset) pagination uses the last-seen row's value as a seek point, producing constant-time queries regardless of depth. It trades random-access page navigation for stable, efficient sequential navigation.
Java
// ── Repository — keyset seek ─────────────────────────────────────────
public interface PostRepository
extends JpaRepository<Post, Long> {
// Forward: fetch posts created before the cursor timestamp
@Query("""
SELECT p FROM Post p
WHERE p.status = 'PUBLISHED'
AND (:cursor IS NULL OR p.createdAt < :cursor
OR (p.createdAt = :cursor AND p.id < :cursorId))
ORDER BY p.createdAt DESC, p.id DESC
LIMIT :size
""")
List<Post> findNextPage(
@Param("cursor") LocalDateTime cursor,
@Param("cursorId") Long cursorId,
@Param("size") int size);
}
// ── Cursor envelope ───────────────────────────────────────────────────
public record CursorPage<T>(
List<T> content,
String nextCursor, // opaque token — clients treat as black box
boolean hasMore
) {}
// ── Cursor token — encodes both timestamp and id ──────────────────────
public record CursorToken(LocalDateTime createdAt, Long id) {
private static final ObjectMapper MAPPER = new ObjectMapper()
.registerModule(new JavaTimeModule());
public String encode() {
try {
String json = MAPPER.writeValueAsString(this);
return Base64.getUrlEncoder()
.encodeToString(json.getBytes(StandardCharsets.UTF_8));
} catch (JsonProcessingException e) {
throw new IllegalStateException("Failed to encode cursor", e);
}
}
public static CursorToken decode(String token) {
try {
byte[] bytes = Base64.getUrlDecoder().decode(token);
return MAPPER.readValue(bytes, CursorToken.class);
} catch (Exception e) {
throw new BadRequestException("Invalid pagination cursor");
}
}
}
// ── Service ───────────────────────────────────────────────────────────
@Service
@RequiredArgsConstructor
public class PostService {
private final PostRepository postRepo;
public CursorPage<PostSummary> findPublished(
String encodedCursor, int size) {
int fetch = size + 1; // one extra to detect hasMore
CursorToken token = encodedCursor != null
? CursorToken.decode(encodedCursor) : null;
List<Post> rows = postRepo.findNextPage(
token != null ? token.createdAt() : null,
token != null ? token.id() : null,
fetch);
boolean hasMore = rows.size() == fetch;
List<PostSummary> content = rows.stream()
.limit(size)
.map(PostSummary::from)
.toList();
String nextCursor = hasMore
? new CursorToken(
rows.get(size - 1).getCreatedAt(),
rows.get(size - 1).getId()).encode()
: null;
return new CursorPage<>(content, nextCursor, hasMore);
}
}
// ── Controller ────────────────────────────────────────────────────────
@GetMapping("/cursor")
public ResponseEntity<CursorPage<PostSummary>> findByCursor(
@RequestParam(required = false) String cursor,
@RequestParam(defaultValue = "20") @Max(100) int size) {
return ResponseEntity.ok(postService.findPublished(cursor, size));
}Pagination Performance
Offset pagination is slow on large tables — OFFSET 10000 forces the database to read and discard 10,000 rows before returning your page. Apply these strategies to keep pagination fast: cap the maximum depth, use Slice instead of Page where totals are unnecessary, use a deferred join to keep the COUNT lean, and prefer cursor pagination for sequential navigation.
Java
// ── 1. Cap maximum offset depth ──────────────────────────────────────
@GetMapping
public ResponseEntity<PageResponse<ProductResponse>> findAll(
@PageableDefault(size = 20) Pageable pageable) {
if (pageable.getPageNumber() > 500) {
throw new BadRequestException(
"Page number must not exceed 500. " +
"Use cursor-based pagination for deep traversal.");
}
return ResponseEntity.ok(
PageResponse.from(productService.findAll(pageable)));
}
// ── 2. Deferred join — cheap COUNT, fast data fetch ───────────────────
// Fetches only IDs in the inner query, then joins the full rows.
// Avoids loading wide rows for the offset scan.
@Query(
value = """
SELECT p.* FROM products p
INNER JOIN (
SELECT id FROM products
WHERE status = :status
ORDER BY created_at DESC
LIMIT :#{#pageable.pageSize}
OFFSET :#{#pageable.offset}
) ids ON p.id = ids.id
""",
countQuery =
"SELECT COUNT(*) FROM products WHERE status = :status",
nativeQuery = true
)
Page<Product> findByStatusDeferred(
@Param("status") String status,
Pageable pageable);
// ── 3. Slice over Page — skip COUNT entirely ──────────────────────────
// Use when total count is not displayed (infinite scroll, load more):
Slice<Product> findByCategory(Long categoryId, Pageable pageable);
// Spring Data fetches pageSize+1, no COUNT query issued.
// ── 4. Fetch join in @Query to avoid N+1 ─────────────────────────────
// Without fetch join, loading orders also fires a query per customer:
@Query("""
SELECT DISTINCT o FROM Order o
LEFT JOIN FETCH o.customer
LEFT JOIN FETCH o.items
WHERE o.status = :status
""")
List<Order> findWithDetails(
@Param("status") OrderStatus status,
Pageable pageable);
// ── 5. application.yml — enable JDBC batching for write performance ───
// spring:
// jpa:
// properties:
// hibernate:
// jdbc:
// batch_size: 50
// order_inserts: true
// order_updates: trueGlobal Pageable Configuration
Configure default page size, maximum page size, and parameter names globally through PageableHandlerMethodArgumentResolver. This avoids repeating @PageableDefault on every controller method and enforces a consistent cap on the page size clients can request.
Java
// ── Global Pageable configuration ────────────────────────────────────
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Override
public void addArgumentResolvers(
List<HandlerMethodArgumentResolver> resolvers) {
PageableHandlerMethodArgumentResolver resolver =
new PageableHandlerMethodArgumentResolver();
resolver.setDefaultPageSize(20); // default when size omitted
resolver.setMaxPageSize(100); // hard cap — client cannot exceed
resolver.setOneIndexedParameters(false); // keep zero-based pages
resolver.setPrefix(""); // no prefix on param names
resolver.setPageParameterName("page"); // ?page=0
resolver.setSizeParameterName("size"); // ?size=20
resolver.setFallbackPageable(
PageRequest.of(0, 20,
Sort.by(Sort.Direction.DESC, "createdAt")));
resolvers.add(resolver);
}
}
// ── application.yml alternative for Spring Data Web ──────────────────
// spring:
// data:
// web:
// pageable:
// default-page-size: 20
// max-page-size: 100
// one-indexed-parameters: false
// page-parameter: page
// size-parameter: size
// ── @PageableDefault — override global defaults per method ───────────
@GetMapping("/featured")
public ResponseEntity<PageResponse<ProductResponse>> featured(
@PageableDefault(
size = 6,
sort = "featuredOrder",
direction = Sort.Direction.ASC
) Pageable pageable) {
return ResponseEntity.ok(
PageResponse.from(productService.findFeatured(pageable)));
}
// ── @SortDefault — default sort without overriding page size ─────────
@GetMapping("/recent")
public ResponseEntity<PageResponse<PostResponse>> recent(
@SortDefault(sort = "publishedAt",
direction = Sort.Direction.DESC)
Pageable pageable) {
return ResponseEntity.ok(
PageResponse.from(postService.findRecent(pageable)));
}