Paginação e Ordenação com Pageable no Spring Boot
1. Visão Geral
Uma API que retorna toda a tabela orders pode funcionar bem com 100 linhas. Quando a tabela chega a um milhão de linhas, essa API derruba a aplicação. Spring Data resolve esse problema com Pageable, um objeto que descreve “qual página pegar, quantos elementos, ordenar por quê”.
Ao terminar de ler este artigo, você saberá como:
- declarar métodos de repository com paginação;
- escolher entre
PageeSlice; - receber os parâmetros
page,size,sortdiretamente da requisição HTTP; - evitar algumas armadilhas de performance e segurança.
2. Preparação
Você precisa de Spring Data JPA, Spring Web e um banco de dados. Este artigo usa H2 para simplicidade:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
A entidade de exemplo é um pedido:
@Entity
@Table(name = "orders")
public class Order {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String customerName;
private BigDecimal total;
@Enumerated(EnumType.STRING)
private OrderStatus status;
private LocalDateTime createdAt;
protected Order() {
}
public Order(String customerName, BigDecimal total, OrderStatus status, LocalDateTime createdAt) {
this.customerName = customerName;
this.total = total;
this.status = status;
this.createdAt = createdAt;
}
// getters
}
public enum OrderStatus { NEW, PAID, SHIPPED }
A tabela é nomeada orders porque ORDER é uma palavra-chave SQL. Se deixar o padrão, muitos bancos de dados retornarão um erro de sintaxe.
3. Paginação na Camada de Repository
3.1. Pageable e PageRequest
JpaRepository já herda PagingAndSortingRepository, então você tem findAll(Pageable) disponível. Para criar um Pageable, você usa PageRequest.of:
Pageable pageable = PageRequest.of(0, 10, Sort.by("createdAt").descending());
Page<Order> page = orderRepository.findAll(pageable);
System.out.println("Página: " + page.getNumber());
System.out.println("Número de elementos nesta página: " + page.getNumberOfElements());
System.out.println("Total de elementos: " + page.getTotalElements());
System.out.println("Total de páginas: " + page.getTotalPages());
System.out.println("Tem próxima página? " + page.hasNext());
O significado de cada parâmetro:
0é o índice da página. Spring Data conta as páginas a partir de 0, não de 1.10é o número de elementos por página.Sort.by("createdAt").descending()ordena os pedidos mais recentes no topo.
Com 25 pedidos na tabela, a saída é:
Página: 0
Número de elementos nesta página: 10
Total de elementos: 25
Total de páginas: 3
Tem próxima página? true
Ative spring.jpa.show-sql=true e você verá Hibernate executar duas declarações SQL:
select o.id, o.created_at, o.customer_name, o.status, o.total
from orders o order by o.created_at desc offset ? rows fetch first ? rows only
select count(o.id) from orders o
A primeira busca os dados da página. A segunda conta o total de linhas para calcular totalElements e totalPages. A sintaxe offset ... fetch first é do H2. Com MySQL ou PostgreSQL, você verá limit ? offset ?.
3.2. Derived Query com Paginação
Você só precisa adicionar o parâmetro Pageable ao final do método. Spring Data cuida do resto:
public interface OrderRepository extends JpaRepository<Order, Long> {
Page<Order> findByStatus(OrderStatus status, Pageable pageable);
Slice<Order> findByCustomerName(String customerName, Pageable pageable);
List<Order> findTop5ByStatus(OrderStatus status, Sort sort);
}
findByStatusretornaPage, acompanhado de umcount.findByCustomerNameretornaSlice. A seção 4 explica a diferença.findTop5ByStatusprecisa apenas de ordenação, não paginação. Neste caso, você passaSortem vez dePageable.
3.3. Combinando Múltiplos Critérios de Ordenação
Sort permite concatenar várias colunas:
Sort sort = Sort.by(Sort.Order.desc("total"), Sort.Order.asc("createdAt"));
Pageable pageable = PageRequest.of(1, 5, sort);
Page<Order> paidOrders = orderRepository.findByStatus(OrderStatus.PAID, pageable);
O código acima busca a segunda página, com elementos de 6 a 10, dos pedidos já pagos. Pedidos com valor total maior são ordenados primeiro. Se dois pedidos têm o mesmo valor, o mais antigo vem primeiro.
Observação: Sempre ordene por pelo menos uma coluna única, por exemplo adicione
idao final. Se ordenar apenas portotale várias linhas tiverem o mesmo valor, o banco de dados pode retornar uma ordem diferente a cada chamada. A consequência é que um registro pode aparecer em duas páginas ou desaparecer de ambas.
4. Page ou Slice?
Page<T> |
Slice<T> |
|
|---|---|---|
Consulta count |
Sim | Não |
getTotalElements(), getTotalPages() |
Sim | Não |
hasNext() |
Sim | Sim |
| Apropriado para | Tabela com numeração de páginas (1, 2, 3 … 50) | Infinite scroll, botão “Ver mais” |
Slice não executa count. Em vez disso, busca size + 1 linhas. Se receber uma linha extra, sabe que tem próxima página:
Slice<Order> slice = orderRepository.findByCustomerName("An", PageRequest.of(0, 10));
System.out.println(slice.getContent().size() + " " + slice.hasNext());
10 true
Com tabelas grandes, count(*) pode ser tão lento quanto a consulta principal. Se a interface não precisa exibir o número total de páginas, use Slice.
5. Recebendo Pageable da Requisição HTTP
5.1. Controller
Spring Boot configura automaticamente PageableHandlerMethodArgumentResolver. Portanto, você só precisa declarar Pageable como parâmetro do controller:
@RestController
@RequestMapping("/orders")
public class OrderController {
private final OrderRepository orderRepository;
public OrderController(OrderRepository orderRepository) {
this.orderRepository = orderRepository;
}
@GetMapping
public PagedModel<OrderResponse> list(
@RequestParam OrderStatus status,
@PageableDefault(size = 20, sort = "createdAt", direction = Sort.Direction.DESC)
Pageable pageable) {
Page<OrderResponse> page = orderRepository.findByStatus(status, pageable)
.map(OrderResponse::from);
return new PagedModel<>(page);
}
}
public record OrderResponse(Long id, String customerName, BigDecimal total, LocalDateTime createdAt) {
static OrderResponse from(Order o) {
return new OrderResponse(o.getId(), o.getCustomerName(), o.getTotal(), o.getCreatedAt());
}
}
Pontos importantes a observar:
@PageableDefaultdefine valores padrão quando o cliente não enviapage,sizeousort.page.map(...)transforma a entidade em um DTO mantendo as informações de paginação. Você não deve retornar entidades diretamente pela API.PagedModel(pacoteorg.springframework.data.web, a partir de Spring Data 3.3 / Boot 3.3) envolvePageem uma estrutura JSON estável.
O cliente chama a API assim:
curl "http://localhost:8080/orders?status=PAID&page=0&size=2&sort=total,desc&sort=id,asc"
{
"content": [
{ "id": 17, "customerName": "Bình", "total": 990000.00, "createdAt": "2026-10-01T09:15:00" },
{ "id": 4, "customerName": "An", "total": 870000.00, "createdAt": "2026-09-28T14:02:00" }
],
"page": { "size": 2, "number": 0, "totalElements": 12, "totalPages": 6 }
}
O parâmetro sort tem o formato propriedade,direção. Você pode repeti-lo várias vezes para ordenar por múltiplas colunas.
5.2. Por que não retornar Page Diretamente?
Retornar Page<OrderResponse> ainda funciona. No entanto, o JSON é resultado de serializar PageImpl, uma classe interna, então a estrutura pode variar entre versões. A partir de Spring Data 3.3, você verá um aviso no log ao fazer isso. Há duas formas de resolver: retornar PagedModel como acima, ou ativar modo de conversão automática para toda a aplicação:
@Configuration
@EnableSpringDataWebSupport(pageSerializationMode = EnableSpringDataWebSupport.PageSerializationMode.VIA_DTO)
public class WebConfig {
}
5.3. Configuração Global
spring.data.web.pageable.default-page-size=20
spring.data.web.pageable.max-page-size=100
spring.data.web.pageable.one-indexed-parameters=false
spring.data.web.pageable.page-parameter=page
spring.data.web.pageable.size-parameter=size
spring.data.web.sort.sort-parameter=sort
max-page-size é a propriedade mais importante. Se o cliente enviar size=1000000, Spring cortará automaticamente para 100. O padrão desse limite é 2000, muito grande para a maioria das APIs.
one-indexed-parameters=true permite que o cliente envie page=1 para a primeira página. Internamente, Pageable ainda usa índices a partir de 0.
6. Paginação com @Query
Com JPQL customizado, Spring Data ainda adiciona automaticamente order by e limit/offset. Também tenta inferir count a partir da sua consulta. Quando a consulta contém join fetch, essa inferência geralmente está errada, então você precisa declarar countQuery separadamente:
@Query(value = "select o from Order o join fetch o.customer c where c.city = :city",
countQuery = "select count(o) from Order o join o.customer c where c.city = :city")
Page<Order> findByCity(@Param("city") String city, Pageable pageable);
Esta seção assume que Order tem um relacionamento @ManyToOne com uma entidade Customer. Com @ManyToOne, join fetch combinado com paginação funciona normalmente.
Observação: Não use
join fetchcom coleções (@OneToMany) durante a paginação. Hibernate não pode aplicarlimitno nível SQL para este caso. Ele carregará todos os dados na memória e depois pagina, enquanto registra o avisoHHH90003004: firstResult/maxResults specified with collection fetch; applying in memory. A abordagem correta é paginar por ID primeiro, depois buscar as coleções pela lista de IDs.
7. Testes
@DataJpaTest inicia um banco de dados incorporado e carrega apenas a camada JPA, perfeito para testar repositórios:
@DataJpaTest
class OrderRepositoryTest {
@Autowired
OrderRepository orderRepository;
@BeforeEach
void setUp() {
LocalDateTime base = LocalDateTime.of(2026, 10, 1, 8, 0);
for (int i = 1; i <= 25; i++) {
orderRepository.save(new Order("Cliente " + i, BigDecimal.valueOf(i * 10_000L),
OrderStatus.PAID, base.plusHours(i)));
}
}
@Test
void shouldReturnSecondPageSortedByTotalDesc() {
Pageable pageable = PageRequest.of(1, 10, Sort.by("total").descending());
Page<Order> page = orderRepository.findByStatus(OrderStatus.PAID, pageable);
assertThat(page.getTotalElements()).isEqualTo(25);
assertThat(page.getTotalPages()).isEqualTo(3);
assertThat(page.getContent()).hasSize(10);
assertThat(page.getContent().getFirst().getTotal())
.isEqualByComparingTo("150000");
assertThat(page.hasNext()).isTrue();
}
@Test
void lastPageShouldContainRemainingOrders() {
Page<Order> page = orderRepository.findByStatus(OrderStatus.PAID, PageRequest.of(2, 10));
assertThat(page.getContent()).hasSize(5);
assertThat(page.isLast()).isTrue();
}
}
No primeiro teste, a página 1 (segunda página) contém elementos de 11 a 20. O primeiro elemento da página é o 11º maior pedido, ou seja, 25 − 10 = 15, correspondendo a 150.000. O segundo teste verifica se a última página contém apenas 5 pedidos.
8. Erros Comuns
Ordenar por atributo inexistente. Requisições como ?sort=passwordHash ou ?sort=abc causam PropertyReferenceException. Se não tratado, o cliente recebe erro 500. Pior ainda, o cliente poderia ordenar por uma coluna sensível para explorar dados. Você deve filtrar por whitelist:
private static final Set<String> SORTABLE = Set.of("createdAt", "total", "id");
static Pageable sanitize(Pageable pageable) {
Sort safe = Sort.by(pageable.getSort().stream()
.filter(order -> SORTABLE.contains(order.getProperty()))
.toList());
return PageRequest.of(pageable.getPageNumber(), pageable.getPageSize(),
safe.isSorted() ? safe : Sort.by("createdAt").descending());
}
Offset muito profundo. page=50000&size=20 força o banco de dados a ler e descartar um milhão de linhas. O tempo de consulta aumenta linearmente com o offset. Com dados grandes ou infinite scroll, use keyset pagination, ou seja, “busque linhas com id < lastSeenId”. Spring Data suporta isso via ScrollPosition e Window.
Confundir página começando em 1. PageRequest.of(1, 10) é a segunda página. Se o frontend contar a partir de 1, ative one-indexed-parameters em vez de subtrair 1 espalhado pelo código.
Dados mudando entre chamadas. Paginação baseada em offset é instável quando novos registros são inseridos. O usuário pode ver um pedido repetido ao virar a página. Keyset pagination evita esse problema.
Quando NÃO usar Page: quando a tabela é muito grande e a interface não precisa do número total de páginas, por exemplo feed ou log. count é um custo extra nesse caso, use Slice ou Window.
9. Conclusão
Pageable combina três informações em um objeto: página, tamanho e ordenação. Spring Data o transforma em limit/offset e order by em todas as camadas, de derived queries, @Query até parâmetros do controller. Use Page quando precisar do número total de páginas e Slice quando só precisar saber se há mais dados. Ao retornar JSON, use PagedModel para manter uma estrutura de resposta estável.
Não esqueça de três coisas: limitar max-page-size, permitir ordenação apenas por atributos da whitelist, e sempre ordenar por uma coluna única. Quando offset começar a ficar lento, é hora de mudar para keyset pagination.