Spring Boot
Sorting
Spring Data turns ?sort=name,asc into a Sort object automatically, either inside a Pageable or as a standalone parameter. Binding is the easy part. The work is deciding which fields clients may sort by, translating public names to entity properties, keeping page results stable, and making sure the database can actually do the sort. This entry covers Sort binding and defaults, a single whitelist-and-rename approach that returns 400 for bad fields, Specifications, and the paging pitfalls (tie-breakers, case-insensitive sorts, deep offsets, keyset scrolling).
Sort via Pageable
When a controller takes a Pageable, Spring binds page, size, and sort from the query string. The sort parameter can be repeated for several fields. Each value is a property name followed by an optional direction.
A subtlety in the syntax: a value with several names and one trailing direction applies that direction to all of them, so sort=lastName,firstName,desc means both descending. To mix directions, repeat the parameter (sort=lastName,asc&sort=createdAt,desc). Direction is case-insensitive. If the last token is not a recognised direction, it is treated as another property name, so a typo like sort=name,dsc fails as an unknown property "dsc".
Set limits globally rather than per endpoint. Spring Boot's default maximum page size is 2000, which is far larger than most APIs should allow. Cap it in configuration, and use @PageableDefault only for endpoint-specific defaults.
Always end a default sort with a unique column such as id. If you sort by a non-unique field like createdAt, rows with equal values can come back in a different order between queries, so a row may appear on two pages or on none while someone pages through.
Java
# application.yml: global paging and sorting settings
spring:
data:
web:
pageable:
default-page-size: 20
max-page-size: 100 # the default is 2000
one-indexed-parameters: false
sort:
sort-parameter: sort
// GET /users?sort=name,asc
// GET /users?sort=lastName,asc&sort=createdAt,desc mixed directions: repeat the parameter
// GET /users?sort=lastName,firstName,desc both fields descending
// GET /users?page=0&size=20&sort=createdAt,desc
@GetMapping
public PagedModel<UserResponse> findAll(
@PageableDefault(size = 20, sort = {"createdAt", "id"}, direction = Sort.Direction.DESC)
Pageable pageable) {
return new PagedModel<>(userService.findAll(pageable));
}
// Sort inside the Pageable becomes: ORDER BY created_at DESC, id DESC
// Unknown property names throw PropertyReferenceException, which is a 500 unless you handle it.
// The next section turns that case into a clean 400.Standalone Sort and Building Sorts in Code
For a list endpoint with no paging, bind Sort directly and use @SortDefault for the default. Repository methods that take a Sort parameter are picked up automatically: the inherited findAll(Sort) already exists, so you only declare your own derived queries.
When you build a Sort in code, prefer the type-safe form so a renamed field breaks compilation instead of production. Two options affect indexes and ordering: ignoreCase() wraps the column in lower(), which can no longer use a plain index on that column; and null handling (nullsFirst, nullsLast) controls where nulls land, which differs by database when you do not specify it.
Java
@GetMapping("/all")
public List<UserResponse> all(@SortDefault(sort = "name", direction = Sort.Direction.ASC) Sort sort) {
return userService.findAll(sort);
}
public interface UserRepository extends JpaRepository<UserEntity, Long> {
List<UserEntity> findByStatus(UserStatus status, Sort sort); // findAll(Sort) is inherited
}
// Building sorts
Sort byName = Sort.by("name"); // ascending by default
Sort newest = Sort.by(Sort.Direction.DESC, "createdAt", "id");
Sort mixed = Sort.by(Sort.Order.desc("createdAt"), Sort.Order.asc("name").nullsLast());
// Type-safe: refactoring-proof
Sort typed = Sort.sort(UserEntity.class).by(UserEntity::getName).ascending();
// Case-insensitive: Order.asc("name").ignoreCase() -> ORDER BY lower(name); plain index not usedSafe Sorting: One Map for Whitelist and Renaming
Be precise about the risk. Spring Data checks sort properties against your entity model, so an arbitrary string is not simply pasted into SQL, and an unknown name fails. The real problems are different: clients can sort by columns you never meant to expose (sorting by a password hash or an internal score leaks information through the ordering), by unindexed columns that make the database sort millions of rows, and by names that cause a 500 with an internal exception message.
The cleanest fix is a single map from public name to entity property. It acts as the whitelist (anything not in the map is rejected) and as the renamer (the API can say fullName while the entity says name) at the same time. Keeping those as two separate steps is how holes appear, for example a mapper that passes unknown names through unchanged, which quietly defeats the whitelist.
Reject unknown fields with a 400 instead of silently dropping them. A client that sends a bad sort should hear about it, rather than get plausible-looking but wrongly ordered data.
Use Sort.Order.withProperty() to rename. It keeps the direction, case-insensitivity, and null handling the client asked for, which rebuilding the order with asc() or desc() would lose.
Java
public class SortRules {
private final Map<String, String> allowed; // public name -> entity property path
private final Sort defaultSort;
public SortRules(Map<String, String> allowed, Sort defaultSort) {
this.allowed = allowed;
this.defaultSort = defaultSort;
}
public Pageable apply(Pageable pageable) {
Sort requested = pageable.getSort();
Sort resolved = requested.isUnsorted()
? defaultSort
: Sort.by(requested.stream().map(order -> {
String property = allowed.get(order.getProperty());
if (property == null) {
throw new InvalidSortException(order.getProperty(), allowed.keySet());
}
return order.withProperty(property); // keeps direction, ignoreCase, nulls
}).toList());
// Unique tie-breaker so pages never overlap or skip rows
boolean hasId = resolved.stream().anyMatch(o -> o.getProperty().equals("id"));
if (!hasId) resolved = resolved.and(Sort.by("id"));
return PageRequest.of(pageable.getPageNumber(), pageable.getPageSize(), resolved);
}
}
public class InvalidSortException extends RuntimeException {
public InvalidSortException(String field, Set<String> allowed) {
super("Cannot sort by '" + field + "'. Allowed: " + new TreeSet<>(allowed));
}
}
@RestController
@RequestMapping("/api/v1/users")
public class UserController {
private static final SortRules SORT = new SortRules(
Map.of("fullName", "name",
"joinDate", "createdAt",
"email", "email",
"status", "status"),
Sort.by(Sort.Direction.DESC, "createdAt"));
private final UserService userService;
public UserController(UserService userService) { this.userService = userService; }
@GetMapping
public PagedModel<UserResponse> findAll(@PageableDefault(size = 20) Pageable pageable) {
return new PagedModel<>(userService.findAll(SORT.apply(pageable)));
}
}
@RestControllerAdvice
class SortErrorHandler {
@ExceptionHandler(InvalidSortException.class)
ProblemDetail invalidSort(InvalidSortException e) {
return ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST, e.getMessage());
}
}
// GET /users?sort=fullName,asc -> ORDER BY name ASC, id ASC
// GET /users?sort=password,asc -> 400 Cannot sort by 'password'. Allowed: [email, fullName, joinDate, status]Sorting with Specifications
With JpaSpecificationExecutor the Specification builds the WHERE clause and the Sort in the Pageable builds the ORDER BY. They are independent, so combine them in the service and run the Pageable through the same SortRules first.
Two cautions. If a filter takes user text for a LIKE, escape the wildcard characters (% and _); otherwise a client searching for "%" matches everything, and "_" matches any single character. And avoid calling query.orderBy inside a Specification unless you skip it for the count query that Page requests need; check query.getResultType() before adding an order. Sorting through the Pageable avoids the problem entirely.
On newer Spring Data JPA versions, Specification.where(...) is deprecated in favour of composition helpers such as allOf(...). Both forms treat a null predicate as "no filter", which is why the factory methods return null for missing values.
Java
public final class UserSpecifications {
public static Specification<UserEntity> hasStatus(UserStatus status) {
return (root, query, cb) -> status == null ? null : cb.equal(root.get("status"), status);
}
public static Specification<UserEntity> nameContains(String name) {
return (root, query, cb) -> {
if (name == null || name.isBlank()) return null;
String escaped = name.toLowerCase()
.replace("!", "!!").replace("%", "!%").replace("_", "!_"); // neutralise wildcards
return cb.like(cb.lower(root.get("name")), "%" + escaped + "%", '!');
};
}
}
public interface UserRepository extends JpaRepository<UserEntity, Long>, JpaSpecificationExecutor<UserEntity> {}
@Service
public class UserService {
private final UserRepository userRepo;
public UserService(UserRepository userRepo) { this.userRepo = userRepo; }
public Page<UserResponse> search(UserStatus status, String name, Pageable pageable) {
Specification<UserEntity> spec = Specification.allOf( // older versions: Specification.where(a).and(b)
UserSpecifications.hasStatus(status),
UserSpecifications.nameContains(name));
return userRepo.findAll(spec, pageable).map(UserResponse::from); // the Pageable carries the sort
}
}
// Controller: SORT.apply(pageable) first, then userService.search(status, name, safePageable)Paging Pitfalls and Performance
Sorting is cheap only when the database can use an index. Sort on indexed columns, and for filtered lists use a composite index that matches the WHERE and ORDER BY together (for example status, then created_at). Sorting by a column from a joined table can add a join to every query, so check the generated SQL for such paths, and avoid sorting by a collection association since it multiplies rows.
Offset paging (page=5000) makes the database read and discard every earlier row, so late pages get slow, and Page also runs a count query on every request. For feeds, infinite scroll, and exports, switch to keyset scrolling (Spring Data 3.1+): instead of a page number you pass the position of the last row you saw, and the database seeks straight to it. It requires a stable sort that ends in a unique key.
Native queries are the exception to everything above: dynamic Sort support for @Query(nativeQuery = true) is limited, so for native SQL map the whitelisted name to a column yourself and append the ORDER BY from your own fixed set of strings, never from client input.
Java
// Keyset scrolling: no offset, no count query
public interface UserRepository extends JpaRepository<UserEntity, Long> {
Window<UserEntity> findFirst20ByStatusOrderByCreatedAtDescIdDesc(UserStatus status, ScrollPosition position);
}
Window<UserEntity> first = userRepo.findFirst20ByStatusOrderByCreatedAtDescIdDesc(ACTIVE, ScrollPosition.keyset());
if (first.hasNext()) {
ScrollPosition next = first.positionAt(first.size() - 1); // hand this back to the client as a cursor
Window<UserEntity> second = userRepo.findFirst20ByStatusOrderByCreatedAtDescIdDesc(ACTIVE, next);
}
-- Index that serves "WHERE status = ? ORDER BY created_at DESC, id DESC"
CREATE INDEX idx_users_status_created ON users (status, created_at DESC, id DESC);Testing Sort Behaviour
Test the contract your clients depend on: a valid sort orders the data, an unlisted field returns 400, and equal values keep a stable order across pages.
Java
@WebMvcTest(UserController.class)
class UserSortTest {
@Autowired MockMvc mvc;
@MockitoBean UserService userService;
@Test
void renamedFieldIsMappedToEntityProperty() throws Exception {
when(userService.findAll(any())).thenReturn(Page.empty());
mvc.perform(get("/api/v1/users?sort=fullName,desc")).andExpect(status().isOk());
ArgumentCaptor<Pageable> captor = ArgumentCaptor.forClass(Pageable.class);
verify(userService).findAll(captor.capture());
assertThat(captor.getValue().getSort())
.isEqualTo(Sort.by(Sort.Order.desc("name"), Sort.Order.asc("id"))); // renamed + tie-breaker
}
@Test
void unlistedFieldIsRejected() throws Exception {
mvc.perform(get("/api/v1/users?sort=password,asc")).andExpect(status().isBadRequest());
}
}