Page в Spring Data JPA: повноцінна пагінація з COUNT — Spring Boot 3

Оновлено:
Page в Spring Data JPA: повноцінна пагінація з COUNT — Spring Boot 3

Page<T> — найпопулярніший тип пагінації в Spring Data JPA, і водночас найчастіше використовуваний там де він не потрібен. Він виконує два SQL-запити при кожному виклику: основний SELECT і COUNT(*). Ключове питання не «як використовувати Page», а «коли він виправданий і як уникнути його типових пасток у продакшені».

⚡ Коротко

  • Page виправданий коли UI показує totalPages, totalElements або numbered pagination
  • COUNT(*) виконується завжди — його не можна відключити, тільки оптимізувати
  • countQuery в @Query — головний інструмент оптимізації для складних запитів
  • ⚠️ JOIN FETCH + Page — небезпечна комбінація, Hibernate може завантажити все в пам'ять
  • 👇 Нижче — механіка, EXPLAIN ANALYZE і production-нюанси

📚 Зміст

Інші статті серії:
📌 List в Spring Data JPA — коли безпечно і коли це ризик
📌 Slice в Spring Data JPA — infinite scroll без COUNT(*)
📌 Specification — динамічні фільтри без combinatorial explosion
📌 List vs Page vs Slice vs Specification — повний гід у Spring Data JPA

🎯 Коли Page виправданий — і коли ні

Коротка відповідь: тільки коли UI використовує totalPages або totalElements

Page<T> виправданий рівно в одному випадку: коли інтерфейс відображає інформацію яка вимагає загальної кількості рядків. Якщо UI не показує «Сторінка 3 з 47», «Знайдено 1 234 результати» або numbered pagination — COUNT(*) виконується даремно.

Page вирішує конкретну задачу: дати відповідь на питання «скільки всього елементів і сторінок». Якщо це питання не стоїть — інструмент обраний неправильно.

Сценарії де Page є правильним вибором

Адмін-панель з numbered pagination. Класичний сценарій: таблиця з користувачами, замовленнями або продуктами, де адміністратор бачить «Показано 41–60 з 234» і може перейти на конкретну сторінку. Тут totalElements і totalPages — обов'язкова частина UI.

Пошукова видача з лічильником. Google показує «Приблизно 1 230 000 результатів». Ваш внутрішній пошук по продуктах або документах може показувати «Знайдено 47 відповідностей» — це вимагає COUNT.

Звіти і таблиці з навігацією. Будь-який звіт де користувач може перейти на останню сторінку або на конкретний номер — вимагає знання загальної кількості сторінок.

Сценарії де Page надлишковий

  • Infinite scroll — потрібен тільки hasNext(), використовуйте Slice
  • «Завантажити ще» — кнопка без лічильника, достатньо Slice
  • Мобільний API — клієнт рідко відображає загальну кількість
  • Стрічка новин або товарів — той самий infinite scroll патерн

🎯 Як працює Page — два запити під капотом

Spring Data завжди генерує два SQL-запити для Page<T>

При кожному виклику методу що повертає Page<T> Hibernate

генерує основний SELECT з LIMIT/OFFSET і окремий COUNT-запит.

target="_blank">За документацією Spring Data, COUNT-запит автоматично

прибирає ORDER BY, відкидає fetch joins і виводить count з основного предикату.

Але для складних запитів ця автоматична оптимізація часто недостатня.

Від Repository до SQL — повний стек

Entity:

@Entity

@Table(name = "orders")

public class Order {

@Id

@GeneratedValue(strategy = GenerationType.IDENTITY)

private Long id;

@ManyToOne(fetch = FetchType.LAZY)

@JoinColumn(name = "user_id")

private User user;

private String status;

private BigDecimal total;

@Column(name = "created_at")

private LocalDateTime createdAt;

}

Repository:

public interface OrderRepository extends JpaRepository<Order, Long> {

Page<Order> findAllByStatusOrderByCreatedAtDesc(

String status,

Pageable pageable

);

}

Service:

@Service

@RequiredArgsConstructor

public class OrderService {

private final OrderRepository orderRepository;

public Page<Order> getOrders(String status, int page, int size) {

Pageable pageable = PageRequest.of(

page, size,

Sort.by("createdAt").descending()

);

return orderRepository.findAllByStatusOrderByCreatedAtDesc(status, pageable);

}

}

SQL що генерує Hibernate — два запити:

-- Запит 1: основні дані

SELECT o.id, o.user_id, o.status, o.total, o.created_at

FROM orders o

WHERE o.status = 'PENDING'

ORDER BY o.created_at DESC

LIMIT 20 OFFSET 0;

-- Запит 2: COUNT — виконується завжди

SELECT COUNT(o.id)

FROM orders o

WHERE o.status = 'PENDING';

-- ORDER BY автоматично прибирається — це правильна оптимізація Spring Data

Controller + DTO:

@RestController

@RequestMapping("/api/admin/orders")

@RequiredArgsConstructor

public class OrderAdminController {

private final OrderService orderService;

@GetMapping

public PageResponse<Order> getOrders(

@RequestParam(defaultValue = "PENDING") String status,

@RequestParam(defaultValue = "0") int page,

@RequestParam(defaultValue = "20") @Max(100) int size) {

Page<Order> result = orderService.getOrders(status, page, size);

return new PageResponse<>(

result.getContent(),

result.getTotalElements(), // для лічильника в UI

result.getTotalPages(), // для numbered pagination

result.getNumber(),

result.hasNext(),

result.hasPrevious()

);

}

}

public record PageResponse<T>(

List<T> content,

long totalElements,

int totalPages,

int currentPage,

boolean hasNext,

boolean hasPrevious

) {}

JSON-відповідь яку отримує фронтенд:

{

"content": [...],

"totalElements": 234, // "Знайдено 234 замовлення"

"totalPages": 12, // для numbered pagination

"currentPage": 0,

"hasNext": true,

"hasPrevious": false

}

Що всередині PageImpl

Spring Data повертає PageImpl<T> — реалізацію інтерфейсу

Page<T>. Конструктор приймає три аргументи:

// Спрощено — що відбувається всередині Spring Data

List<Order> content = // результат SELECT з LIMIT/OFFSET

long totalElements = // результат COUNT(*)

Pageable pageable = // переданий PageRequest

return new PageImpl<>(content, pageable, totalElements);

// totalPages обчислюється як Math.ceil(totalElements / pageSize)

🎯 COUNT(*) під мікроскопом — EXPLAIN ANALYZE

Коротка відповідь: COUNT деградує з фільтрами і JOIN — потрібні індекси і countQuery

Поведінка COUNT суттєво відрізняється залежно від складності запиту.

Простий COUNT по первинному ключу майже безкоштовний.

COUNT з WHERE по неіндексованому полю — повний table scan.

COUNT з JOIN — потенційний cartesian product якщо Spring Data

не може правильно спростити запит.

Сценарій 1: простий COUNT без фільтрів

EXPLAIN ANALYZE SELECT COUNT(o.id) FROM orders o;

-- Index Only Scan using orders_pkey on orders

-- Heap Fetches: 0

-- Planning Time: 0.1 ms

-- Execution Time: 12 ms -- 1M рядків, index-only scan

PostgreSQL використовує index-only scan по первинному ключу.

При 1M рядків — 12ms. Прийнятно для більшості сценаріїв.

Сценарій 2: COUNT з фільтром по неіндексованому полю

EXPLAIN ANALYZE

SELECT COUNT(o.id) FROM orders o

WHERE o.status = 'PENDING';

-- Seq Scan on orders

-- Filter: (status = 'PENDING')

-- Rows Removed by Filter: 850000

-- Planning Time: 0.2 ms

-- Execution Time: 340 ms -- повний table scan при 1M рядків

340ms на COUNT при кожному запиті сторінки. Рішення — індекс на status:

CREATE INDEX idx_orders_status ON orders(status);

-- Після індексу:

-- Bitmap Index Scan on idx_orders_status

-- Index Cond: (status = 'PENDING')

-- Execution Time: 8 ms -- в 42 рази швидше

Сценарій 3: COUNT з JOIN — небезпечна автогенерація

Це найпроблемніший сценарій.

Якщо основний запит містить JOIN FETCH, Spring Data намагається

автоматично спростити COUNT-запит — але не завжди успішно:

// Repository з JOIN FETCH

@Query("SELECT o FROM Order o JOIN FETCH o.user u WHERE u.role = :role")

Page<Order> findByUserRole(@Param("role") String role, Pageable pageable);

-- Автогенерований COUNT (проблемний):

SELECT COUNT(DISTINCT o.id)

FROM orders o

INNER JOIN users u ON o.user_id = u.id

WHERE u.role = 'ADMIN';

-- JOIN залишається в COUNT — дорожче ніж потрібно

-- Попередження в логах Hibernate:

-- HHH90003004: firstResult/maxResults specified with collection fetch;

-- applying in memory — Hibernate може завантажити ВСЕ в пам'ять

Останнє попередження критичне: при певних комбінаціях JOIN FETCH і Page,

Hibernate застосовує LIMIT/OFFSET в пам'яті, а не в SQL.

Це означає завантаження всіх рядків з БД і фільтрацію в JVM.

Активуйте property щоб отримати exception замість мовчазної деградації:

# application.properties

spring.jpa.properties.hibernate.query.fail_on_pagination_over_collection_fetch=true

🎯 Оптимізація Page в продакшені

countQuery, індекси і обмеження OFFSET — три головних інструменти

Для складних запитів автоматично згенерований COUNT часто неоптимальний.

countQuery в @Query дозволяє написати спрощений

COUNT вручну — без JOIN і зайвих умов. Це найефективніша оптимізація

для запитів з кількома таблицями.

countQuery — ручна оптимізація COUNT

@Query(

value = """

SELECT o FROM Order o

JOIN FETCH o.user u

JOIN FETCH o.items i

WHERE u.role = :role

AND o.status = :status

""",

countQuery = """

SELECT COUNT(o.id) FROM Order o

JOIN o.user u

WHERE u.role = :role

AND o.status = :status

"""

// JOIN FETCH замінений на JOIN

// o.items прибраний — не потрібен для COUNT

)

Page<Order> findByRoleAndStatus(

@Param("role") String role,

@Param("status") String status,

Pageable pageable

);

Порівняння EXPLAIN для автогенерованого vs кастомного COUNT:

-- Автогенерований COUNT з JOIN FETCH items:

SELECT COUNT(DISTINCT o.id)

FROM orders o

JOIN users u ON o.user_id = u.id

JOIN order_items i ON o.id = i.order_id -- зайвий JOIN

WHERE u.role = 'ADMIN' AND o.status = 'PENDING';

-- Execution Time: 280 ms

-- Кастомний countQuery:

SELECT COUNT(o.id)

FROM orders o

JOIN users u ON o.user_id = u.id -- тільки потрібний JOIN

WHERE u.role = 'ADMIN' AND o.status = 'PENDING';

-- Execution Time: 45 ms -- в 6 разів швидше

PageableHandlerMethodArgumentResolver — обмеження з коробки

Spring MVC автоматично резолвить Pageable з HTTP-параметрів.

Без налаштувань клієнт може передати size=100000.

Обмежуйте через конфігурацію:

@Configuration

public class WebConfig implements WebMvcConfigurer {

@Override

public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) {

PageableHandlerMethodArgumentResolver resolver =

new PageableHandlerMethodArgumentResolver();

resolver.setMaxPageSize(100); // максимум 100 елементів

resolver.setFallbackPageable(

PageRequest.of(0, 20) // дефолт якщо не передано

);

resolvers.add(resolver);

}

}

@QueryHints для readonly-запитів

Адмін-панелі зазвичай тільки читають дані. Позначте це явно —

Hibernate пропустить dirty checking і зменшить overhead:

@QueryHints(value = {

@QueryHint(name = HINT_READONLY, value = "true"),

@QueryHint(name = HINT_FETCH_SIZE, value = "50")

})

Page<Order> findAllByStatusOrderByCreatedAtDesc(String status, Pageable pageable);

Ліміт на OFFSET — захист від деградації

При page=1000, size=20 PostgreSQL генерує OFFSET 20000

— читає і відкидає 20 000 рядків. Для адмін-панелей це рідкісний сценарій,

але варто захиститись:

@GetMapping

public PageResponse<Order> getOrders(

@RequestParam(defaultValue = "0") @Max(500) int page,

@RequestParam(defaultValue = "20") @Max(100) int size) {

// page * size максимум 500 * 20 = OFFSET 10000

// якщо потрібно більше — пошук або фільтр, не пагінація

}

🎯 Page vs Slice vs List — чіткі критерії

Один критерій — чи показує UI загальну кількість

Якщо UI відображає кількість елементів або сторінок — потрібен Page.

Якщо UI показує тільки «є ще / немає» — достатньо Slice.

Якщо дані завжди маленькі і завантажуються повністю — List.

КритерійListSlicePage
COUNT(*) запит✅ завжди
totalElements / totalPages
hasNext()
Numbered pagination (1..2..3)
Infinite scroll⚠️ без ліміту✅ оптимально⚠️ зайвий COUNT
Адмін-панель
Пошукова видача з лічильником
Мобільний API⚠️⚠️
Ризик OutOfMemory⚠️ без LIMIT✅ захищений✅ захищений
JOIN FETCH сумісність⚠️ потребує countQuery

❓ FAQ

Чи можна відключити COUNT(*) для Page?

Ні — COUNT є частиною контракту Page<T> і не може бути відключений

через конфігурацію. Якщо COUNT не потрібен — використовуйте Slice<T>.

Єдина можливість впливати на COUNT — написати оптимізований countQuery

в @Query, але він все одно виконається.

Чому Spring Data прибирає ORDER BY з COUNT-запиту?

Це свідома оптимізація: ORDER BY не впливає на результат COUNT і лише

додає overhead. Spring Data автоматично видаляє сортування з COUNT-запиту.

Якщо ви пишете кастомний countQuery — також не включайте ORDER BY.

Page з нативним SQL (@Query nativeQuery=true) — є нюанси?

Так, і суттєві. При нативних запитах Spring Data не може автоматично

згенерувати коректний COUNT — він часто виконує SELECT * FROM (...) AS count_query

що призводить до повного виконання основного запиту заради COUNT.

При 600K+ рядків це може займати 20+ секунд.

Для нативних запитів завжди вказуйте countQuery явно.

@Query(

value = "SELECT * FROM orders WHERE status = :status",

countQuery = "SELECT COUNT(*) FROM orders WHERE status = :status",

nativeQuery = true

)

Page<Order> findByStatusNative(@Param("status") String status, Pageable pageable);

Як передати Pageable через REST API?

Spring MVC автоматично резолвить Pageable якщо він є параметром

контролера. Параметри за замовчуванням: ?page=0&size=20&sort=createdAt,desc.

Сортування можна передавати кількома полями: sort=status,asc&sort=createdAt,desc.

Обмежуйте максимальний size через PageableHandlerMethodArgumentResolver

або @PageableDefault.

// Через анотацію — швидше для одного endpoint

@GetMapping

public PageResponse<Order> getOrders(

@PageableDefault(size = 20, sort = "createdAt", direction = Sort.Direction.DESC)

Pageable pageable) { ... }

PageImpl vs Page — яка різниця і коли потрібен PageImpl вручну?

Page<T> — інтерфейс, PageImpl<T> — його реалізація.

Spring Data повертає PageImpl автоматично. Вручну створювати PageImpl

потрібно в двох випадках: при unit-тестах де потрібно замокати результат пагінації,

і при ручній трансформації результату (наприклад, маппінг Entity → DTO зі збереженням

метаданих пагінації).

// Маппінг Entity → DTO зі збереженням пагінації

Page<Order> orders = orderRepository.findAll(pageable);

Page<OrderDto> dtos = orders.map(order -> new OrderDto(order));

// або

Page<OrderDto> dtos = new PageImpl<>(

orders.getContent().stream().map(OrderDto::from).toList(),

pageable,

orders.getTotalElements() // зберігаємо оригінальний COUNT

);

✅ Висновки

Page<T> — правильний інструмент для numbered pagination

і будь-якого UI що показує загальну кількість елементів або сторінок.

Він виконує два SQL-запити при кожному виклику — це не баг, це контракт.

  • COUNT(*) виконується завжди — відключити неможливо, тільки оптимізувати через countQuery
  • Для складних запитів з JOIN пишіть countQuery вручну — автогенерація часто неоптимальна
  • JOIN FETCH + Page — небезпечна комбінація, активуйте fail_on_pagination_over_collection_fetch
  • Нативні запити завжди вимагають явного countQuery
  • Обмежуйте максимальний size і page на рівні контролера
  • Якщо UI не показує загальну кількість — використовуйте Slice

Наступні статті серії:

📌 Spring Data JPA — Pageable and Page documentation

Vlad Mihalcea — JOIN FETCH and Pagination with Spring

Spring Boot Pagination Performance in REST Endpoints

PostgreSQL — EXPLAIN документація

🌟 З повагою

Вадим Харовюк

☕ Java розробник, засновник WebCraft Studio

Останні статті

Читайте більше цікавих матеріалів

List vs Page vs Slice vs Specification у Spring Data JPA: повний гід Spring Boot 3

List vs Page vs Slice vs Specification у Spring Data JPA: повний гід Spring Boot 3

Spring Data JPA пропонує чотири принципово різних підходи до отримання даних.Кожен з них генерує різний SQL, по-різному поводиться з пам'яттюі вирішує різний клас задач. Неправильний вибір — це або зайвийCOUNT(*) при кожному scroll-івенті, або OOM при зростанні таблиці,або 64 derived methods...

List в Spring Data JPA: коли безпечно і коли це ризик — Spring Boot 3

List в Spring Data JPA: коли безпечно і коли це ризик — Spring Boot 3

List&lt;T&gt; — найпростіший тип повернення в Spring Data JPA. Саме тому він найчастіше використовується там де не повинен. Відсутність LIMIT у згенерованому SQL означає: розмір результату нічим не обмежений. При таблиці на 100K+ рядків це пряма дорога до OutOfMemory або...

Specification в Spring Data JPA: динамічні запити без combinatorial explosion — Spring Boot 3

Specification в Spring Data JPA: динамічні запити без combinatorial explosion — Spring Boot 3

Коли фільтрів в адмін-панелі стає більше трьох — derived methods перетворюютьсяна комбінаторний вибух: 4 опціональних фільтри дають 16 можливих комбінаційі стільки ж методів у Repository. Specification вирішує цю проблему:один метод, будь-яка комбінація фільтрів, чистий SQL під капотом.⚡ Коротко✅...

Page в Spring Data JPA: повноцінна пагінація з COUNT — Spring Boot 3

Page в Spring Data JPA: повноцінна пагінація з COUNT — Spring Boot 3

Page&lt;T&gt; — найпопулярніший тип пагінації в Spring Data JPA, і водночас найчастіше використовуваний там де він не потрібен. Він виконує два SQL-запити при кожному виклику: основний SELECT і COUNT(*). Ключове питання не «як використовувати Page», а «коли він...

Slice в Spring Data JPA: легка пагінація для infinite scroll у Spring Boot 3

Slice в Spring Data JPA: легка пагінація для infinite scroll у Spring Boot 3

Більшість розробників за замовчуванням обирають Page&lt;T&gt; для пагінації — і платять за це зайвим COUNT(*) запитом при кожному scroll-івенті. Slice вирішує саме цю проблему: повертає наступну порцію даних без підрахунку загальної кількості рядків — і саме тому є...

OpenAI випустив GPT-5.4: що змінилось  в 2026

OpenAI випустив GPT-5.4: що змінилось в 2026

5 березня 2026 року OpenAI випустив GPT-5.4 — одночасно у ChatGPT, API і Codex.Це не черговий incremental update: модель вперше об'єднує coding pipeline GPT-5.3-Codexіз загальним reasoning, отримує native computer use і контекстне вікно до 1M токенів.Коротко: якщо ви будуєте агентні воркфлоу або...