Paginación y ordenamiento con Pageable en Spring Boot

1. Resumen

Una API que devuelve toda la tabla orders puede funcionar bien con 100 filas. Cuando la tabla crece a un millón de filas, esa API colapsa la aplicación. Spring Data resuelve este problema con Pageable, un objeto que describe “qué página tomar, cuántos elementos, y cómo ordenar”.

Después de leer este artículo, sabrás cómo:

  • declarar métodos de repositorio con paginación;
  • elegir entre Page y Slice;
  • recibir los parámetros page, size, sort directamente de la solicitud HTTP;
  • evitar algunas trampas sobre rendimiento y seguridad.

2. Preparación

Necesitas Spring Data JPA, Spring Web y una base de datos. Este artículo usa H2 para simplificar:

<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>

La entidad de ejemplo es un 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 }

La tabla se llama orders porque ORDER es una palabra clave SQL. Si se deja por defecto, muchas bases de datos reportarán un error de sintaxis.

3. Paginación en la capa de repositorio

3.1. Pageable y PageRequest

JpaRepository ya hereda de PagingAndSortingRepository, por lo que tienes disponible findAll(Pageable). Para crear un Pageable, usas 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("Elementos en esta página: " + page.getNumberOfElements());
System.out.println("Total de elementos: " + page.getTotalElements());
System.out.println("Total de páginas: " + page.getTotalPages());
System.out.println("¿Hay página siguiente? " + page.hasNext());

El significado de cada parámetro:

  • 0 es el índice de la página. Spring Data cuenta las páginas desde 0, no desde 1.
  • 10 es el número de elementos por página.
  • Sort.by("createdAt").descending() ordena los pedidos más recientes primero.

Con 25 pedidos en la tabla, el resultado es:

Página: 0
Elementos en esta página: 10
Total de elementos: 25
Total de páginas: 3
¿Hay página siguiente? true

Si habilitas spring.jpa.show-sql=true, verás que Hibernate ejecuta dos consultas 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

La primera consulta obtiene los datos de la página. La segunda consulta cuenta el número total de filas para calcular totalElements y totalPages. La sintaxis offset ... fetch first es de H2. Con MySQL o PostgreSQL, verás limit ? offset ?.

3.2. Consultas derivadas con paginación

Solo necesitas agregar el parámetro Pageable al final del método. Spring Data maneja el resto automáticamente:

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 devuelve Page, que incluye una consulta count.
  • findByCustomerName devuelve Slice. La sección 4 explicará la diferencia.
  • findTop5ByStatus solo necesita ordenamiento sin paginación. En este caso, pasas Sort en lugar de Pageable.

3.3. Combinación de múltiples criterios de ordenamiento

Sort te permite encadenar múltiples columnas:

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);

El código anterior obtiene la segunda página, que contiene elementos del 6 al 10, de los pedidos ya pagados. Los pedidos con montos más altos se clasifican primero. Si dos pedidos tienen el mismo monto, el más antiguo aparece primero.

Nota: Siempre ordena incluyendo al menos una columna de identificación única, como agregar id al final. Si solo ordenas por total y muchas filas tienen el mismo valor, la base de datos puede devolver un orden diferente entre llamadas. Como resultado, un registro puede aparecer en dos páginas o desaparecer de ambas.

4. ¿Page o Slice?

Page<T> Slice<T>
Consulta count Sí No
getTotalElements(), getTotalPages() Sí No
hasNext() Sí Sí
Ideal para Paginación numerada (1, 2, 3 … 50) Infinite scroll, botón “Ver más”

Slice no ejecuta la consulta count. En su lugar, obtiene size + 1 filas. Si recibe la fila adicional, sabe que hay una página siguiente:

Slice<Order> slice = orderRepository.findByCustomerName("An", PageRequest.of(0, 10));
System.out.println(slice.getContent().size() + " " + slice.hasNext());
10 true

Con tablas grandes, count(*) puede ser tan lenta como la consulta principal. Si la interfaz no necesita mostrar el número total de páginas, usa Slice.

5. Recibiendo Pageable de la solicitud HTTP

5.1. Controlador

Spring Boot configura automáticamente PageableHandlerMethodArgumentResolver. Por lo tanto, declarar Pageable como parámetro del controlador es suficiente:

@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());
    }
}

Puntos a notar:

  • @PageableDefault establece valores predeterminados cuando el cliente no envía page, size o sort.
  • page.map(...) convierte la entidad a DTO mientras conserva la información de paginación. No debes devolver entidades a través de la API.
  • PagedModel (paquete org.springframework.data.web, disponible desde Spring Data 3.3 / Boot 3.3) envuelve Page en una estructura JSON estable.

El cliente llama a la API así:

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 }
}

El parámetro sort tiene la forma propiedad,dirección. Puedes repetirlo varias veces para ordenar por múltiples columnas.

5.2. ¿Por qué no devolver Page directamente?

Devolver Page<OrderResponse> aún funciona. Sin embargo, el JSON sería el resultado de serializar PageImpl, una clase interna, por lo que la estructura puede cambiar entre versiones.

A partir de Spring Data 3.3, verás una advertencia en los logs cuando hagas esto.

Hay dos formas de manejarlo: devuelve PagedModel como arriba, o habilita la conversión automática para toda la aplicación:

@Configuration
@EnableSpringDataWebSupport(pageSerializationMode = EnableSpringDataWebSupport.PageSerializationMode.VIA_DTO)
public class WebConfig {
}

5.3. Configuración 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 es la propiedad más importante. Si el cliente envía size=1000000, Spring lo limitará automáticamente a 100. El límite predeterminado es 2000, que es demasiado grande para la mayoría de las APIs.

one-indexed-parameters=true permite al cliente enviar page=1 para la primera página. Internamente, Pageable sigue usando índices desde 0.

6. Paginación con @Query

Con JPQL escrito a mano, Spring Data aún agrega automáticamente order by y limit/offset. También intenta inferir la consulta count de tu consulta.

Cuando la consulta tiene join fetch, esta inferencia a menudo falla, por lo que necesitas declarar countQuery por separado:

@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 sección asume que Order tiene una relación @ManyToOne con una entidad Customer.

Con @ManyToOne, join fetch combinado con paginación funciona normalmente.

Nota: No uses join fetch con colecciones (@OneToMany) cuando pagines. Hibernate no puede aplicar limit a nivel SQL en este caso. Cargará todos los datos en memoria primero y luego pagará, mientras registra una advertencia HHH90003004: firstResult/maxResults specified with collection fetch; applying in memory. La forma correcta es paginar en los IDs primero, luego obtener las colecciones por esa lista de IDs.

7. Pruebas

@DataJpaTest inicia una base de datos integrada y solo carga la capa JPA, por lo que es ideal para probar repositorios:

@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();
    }
}

En la primera prueba, la página 1 (segunda página) contiene los elementos 11 a 20. El primer elemento de la página es el 11º pedido más grande, es decir 25 − 10 = 15, que corresponde a 150.000.

La segunda prueba verifica que la última página solo contiene 5 pedidos.

8. Errores comunes

Ordenar por una propiedad inexistente. Las solicitudes ?sort=passwordHash o ?sort=abc causan PropertyReferenceException. Si no lo manejas, el cliente recibe un error 500. Peor aún, el cliente podría ordenar por una columna sensible para explorar datos. Deberías filtrar por una lista blanca:

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 muy profundo. page=50000&size=20 obliga a la base de datos a leer y omitir un millón de filas. El tiempo de consulta aumenta linealmente con el offset. Con datos grandes o infinite scroll, usa keyset pagination, es decir, “obtén las filas donde id < lastSeenId”. Spring Data soporta esto a través de ScrollPosition y Window.

Confundir el inicio de la página con 1. PageRequest.of(1, 10) es la segunda página. Si el frontend cuenta desde 1, habilita one-indexed-parameters en lugar de restar 1 esparcido por todo el código.

Los datos cambian entre llamadas. La paginación basada en offset no es estable cuando se insertan nuevos registros. Un usuario podría ver un pedido repetido al cambiar de página. Keyset pagination evita este problema.

Cuándo NO usar Page: cuando la tabla es muy grande y la interfaz no necesita el número total de páginas, por ejemplo en un feed o log. La consulta count es un costo innecesario en ese caso, así que usa Slice o Window.

9. Conclusión

Pageable agrupa tres piezas de información en un objeto: página, tamaño y cómo ordenar. Spring Data lo convierte en limit/offset y order by en todas las capas, desde consultas derivadas, @Query hasta parámetros del controlador. Usa Page cuando necesites el número total de páginas y Slice cuando solo necesites saber si hay más datos. Al devolver JSON, usa PagedModel para una estructura de respuesta estable.

No olvides tres cosas: limita max-page-size, permite ordenar solo por propiedades en la lista blanca, y siempre ordena por una columna única. Cuando el offset comience a ser lento, es hora de cambiar a keyset pagination.

spring-bootspring-data-jpapageablerest-apijpa