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 Page e Slice;
  • receber os parâmetros page, size, sort diretamente 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);
}
  • findByStatus retorna Page, acompanhado de um count.
  • findByCustomerName retorna Slice. A seção 4 explica a diferença.
  • findTop5ByStatus precisa apenas de ordenação, não paginação. Neste caso, você passa Sort em vez de Pageable.

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 id ao final. Se ordenar apenas por total e 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:

  • @PageableDefault define valores padrão quando o cliente não envia page, size ou sort.
  • 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 (pacote org.springframework.data.web, a partir de Spring Data 3.3 / Boot 3.3) envolve Page em 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 fetch com coleções (@OneToMany) durante a paginação. Hibernate não pode aplicar limit no nível SQL para este caso. Ele carregará todos os dados na memória e depois pagina, enquanto registra o aviso HHH90003004: 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.

spring-bootspring-data-jpapageablerest-apijpa